LocalAuthority / scripts / host_manager.pl
Newer Older
4897 lines | 194.717kb
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 4 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 4 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 4 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 4 days ago
421
    my $dbh = dbh();
Bogdan Timofte authored 4 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 4 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 4 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 4 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 4 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 4 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.hosts_ip,
493
    h.dns_ip,
494
    h.monitoring,
495
    h.status AS host_status,
496
    c.common_name,
497
    c.not_after,
498
    c.fingerprint_sha256,
499
    c.status AS certificate_status
500
FROM vhosts v
501
JOIN hosts h ON h.fqdn = v.host_fqdn
502
LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
503
WHERE v.status = 'active'
504
ORDER BY v.vhost_fqdn
505
SQL
506
    $sth->execute;
507
    while (my $row = $sth->fetchrow_hashref) {
508
        my $cert_id = clean_scalar($row->{certificate_id} || '');
509
        my %certificate = $cert_id ? (
510
            id => $cert_id,
511
            name => $cert_id,
512
            common_name => clean_scalar($row->{common_name} || ''),
513
            status => clean_scalar($row->{certificate_status} || ''),
514
            not_after => clean_scalar($row->{not_after} || ''),
515
            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
Bogdan Timofte authored 4 days ago
516
            has_private_key => json_bool(ca_private_key_exists($cert_id)),
Bogdan Timofte authored 4 days ago
517
        ) : ();
518
        push @rows, {
519
            vhost => $row->{vhost_fqdn},
520
            vhost_fqdn => $row->{vhost_fqdn},
521
            host_id => $row->{legacy_id} || '',
522
            host_fqdn => $row->{host_fqdn},
523
            ip => $row->{hosts_ip} || $row->{dns_ip} || '',
524
            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
525
            monitoring => $row->{monitoring} || '',
526
            status => $row->{host_status} || $row->{vhost_status} || '',
527
            vhost_status => $row->{vhost_status} || '',
528
            certificate_id => $cert_id,
529
            certificate => $cert_id ? \%certificate : undef,
530
        };
531
    }
532
    return @rows;
533
}
534

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
696
    my $result = eval {
697
        with_transaction($dbh, sub {
698
            my $now = iso_now();
699
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
700

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
946
sub declared_dns_names {
947
    my ($host) = @_;
948
    my @names;
949
    my $fqdn = canonical_host_fqdn($host);
950
    push @names, $fqdn if length $fqdn;
951
    push @names, declared_alias_names($host);
952
    push @names, declared_vhost_names($host);
953
    return unique_preserve(@names);
954
}
955

            
956
sub declared_alias_names {
957
    my ($host) = @_;
958
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
959
}
960

            
961
sub declared_vhost_names {
962
    my ($host) = @_;
963
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
964
}
965

            
966
sub declared_dns_names_legacy {
967
    my ($host) = @_;
968
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
969
}
970

            
971
sub split_legacy_names {
972
    my ($id, $names) = @_;
973
    my $fallback = clean_id($id || '');
974
    my (%result) = (
975
        fqdn => '',
976
        aliases => [],
977
        vhosts => [],
978
    );
979
    for my $name (map { normalize_dns_name($_) } @$names) {
980
        next unless length $name;
981
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
982
            $result{fqdn} = $name;
983
            next;
984
        }
985
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
986
            $result{fqdn} = $name;
987
            next;
988
        }
989
        if (name_is_vhost($name)) {
990
            push @{ $result{vhosts} }, $name;
991
        } else {
992
            push @{ $result{aliases} }, $name;
993
        }
994
    }
995
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
996
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
997
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
998
    return \%result;
999
}
1000

            
1001
sub derived_alias_names {
Xdev Host Manager authored a week ago
1002
    my ($host) = @_;
1003
    my @derived;
Bogdan Timofte authored 4 days ago
1004
    my $fqdn = canonical_host_fqdn($host);
1005
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1006
    for my $name (declared_alias_names($host)) {
1007
        push @derived, short_alias_for_fqdn($name);
1008
    }
1009
    return unique_preserve(grep { length $_ } @derived);
1010
}
1011

            
1012
sub derived_vhost_alias_names {
1013
    my ($host) = @_;
1014
    my @derived;
1015
    for my $name (declared_vhost_names($host)) {
1016
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
1017
    }
Bogdan Timofte authored 4 days ago
1018
    return unique_preserve(grep { length $_ } @derived);
1019
}
1020

            
1021
sub clean_alias_names {
1022
    my ($payload) = @_;
1023
    return clean_name_bucket($payload->{aliases})
1024
        if defined $payload->{aliases};
1025
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1026
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1027
}
1028

            
1029
sub clean_vhost_names {
1030
    my ($payload) = @_;
1031
    return clean_name_bucket($payload->{vhosts})
1032
        if defined $payload->{vhosts};
1033
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1034
    return grep { name_is_vhost($_) } @legacy;
1035
}
1036

            
1037
sub clean_name_bucket {
1038
    my ($value) = @_;
1039
    my @names = clean_list($value);
1040
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1041
}
1042

            
1043
sub remove_derived_names {
1044
    my @names = @_;
1045
    my %derived;
1046
    for my $name (@names) {
1047
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1048
        $derived{$1} = 1;
1049
    }
1050
    return grep { !$derived{$_} } @names;
1051
}
1052

            
1053
sub unique_preserve {
1054
    my @values = @_;
1055
    my %seen;
1056
    return grep { !$seen{$_}++ } @values;
1057
}
1058

            
Bogdan Timofte authored 4 days ago
1059
sub canonical_ip {
1060
    my ($host) = @_;
1061
    return '' unless $host && ref($host) eq 'HASH';
1062
    for my $key (qw(ip dns_ip hosts_ip)) {
1063
        my $value = clean_scalar($host->{$key} || '');
1064
        return $value if length $value;
1065
    }
1066
    return '';
1067
}
1068

            
Xdev Host Manager authored a week ago
1069
sub problem {
1070
    my ($host, $code, $message) = @_;
1071
    return { host_id => $host->{id}, code => $code, message => $message };
1072
}
1073

            
1074
sub render_local_hosts_tsv {
1075
    my ($registry) = @_;
1076
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
1077
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
1078
    $out .= "#\n";
1079
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
1080
    $out .= "# ip<TAB>name [aliases...]\n";
Xdev Host Manager authored a week ago
1081
    $out .= "#\n";
1082
    $out .= "# Priority rule:\n";
1083
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
1084
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
1085
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
1086
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1087
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
1088
        my $ip = canonical_ip($host);
1089
        next unless $ip;
Xdev Host Manager authored a week ago
1090
        my @names = effective_names($host);
1091
        next unless @names;
Bogdan Timofte authored 4 days ago
1092
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
1093
    }
1094
    return $out;
1095
}
1096

            
1097
sub render_monitoring {
1098
    my ($registry) = @_;
1099
    my @hosts;
1100
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1101
        next unless ($host->{status} || 'active') eq 'active';
1102
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
1103
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
1104
        push @hosts, {
1105
            id => $host->{id},
Xdev Host Manager authored a week ago
1106
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
1107
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
1108
            aliases => \@names,
Bogdan Timofte authored 4 days ago
1109
            fqdn => canonical_host_fqdn($host),
1110
            declared_names => [ declared_dns_names($host) ],
1111
            aliases_declared => [ declared_alias_names($host) ],
1112
            aliases_derived => [ derived_alias_names($host) ],
1113
            vhosts_declared => [ declared_vhost_names($host) ],
1114
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
1115
            roles => [ @{ $host->{roles} || [] } ],
1116
            monitoring => $host->{monitoring} || 'pending',
1117
            notes => $host->{notes} || '',
1118
        };
1119
    }
1120
    return {
1121
        version => $registry->{version},
1122
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
1123
        source => $opt{db},
Xdev Host Manager authored a week ago
1124
        hosts => \@hosts,
1125
    };
1126
}
1127

            
Bogdan Timofte authored 4 days ago
1128
sub debug_database_tables_payload {
1129
    my $dbh = dbh();
1130
    my @tables;
1131
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
1132
    $sth->execute;
1133
    while (my ($name) = $sth->fetchrow_array) {
1134
        my $quoted = $dbh->quote_identifier($name);
1135
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1136
        push @tables, {
1137
            name => $name,
1138
            rows => int($count || 0),
1139
        };
1140
    }
1141
    return {
1142
        database => $opt{db},
1143
        generated_at => iso_now(),
1144
        tables => \@tables,
1145
        counts => {
1146
            tables => scalar @tables,
1147
            rows => sum(map { $_->{rows} } @tables),
1148
        },
1149
    };
1150
}
1151

            
1152
sub debug_database_table_payload {
1153
    my ($table, $limit) = @_;
1154
    my $dbh = dbh();
1155
    $table = clean_scalar($table);
1156
    return { error => 'missing_table' } unless length $table;
1157
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1158
    $limit = int($limit || 100);
1159
    $limit = 1 if $limit < 1;
1160
    $limit = 500 if $limit > 500;
1161

            
1162
    my $quoted = $dbh->quote_identifier($table);
1163
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1164
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1165
    my @index_details;
1166
    for my $index (@$indexes) {
1167
        my $index_name = $index->{name} || '';
1168
        next unless length $index_name;
1169
        my $quoted_index = $dbh->quote_identifier($index_name);
1170
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1171
        push @index_details, {
1172
            name => $index_name,
1173
            unique => int($index->{unique} || 0),
1174
            origin => $index->{origin} || '',
1175
            partial => int($index->{partial} || 0),
1176
            columns => [ map { $_->{name} || '' } @$index_columns ],
1177
        };
1178
    }
1179
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1180
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1181
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1182

            
1183
    return {
1184
        database => $opt{db},
1185
        table => $table,
1186
        generated_at => iso_now(),
1187
        limit => $limit,
1188
        row_count => int($row_count || 0),
1189
        columns => $columns,
1190
        indexes => \@index_details,
1191
        foreign_keys => $foreign_keys,
1192
        rows => $rows,
1193
    };
1194
}
1195

            
Bogdan Timofte authored 4 days ago
1196
sub debug_database_table_export_payload {
1197
    my ($table) = @_;
1198
    my $dbh = dbh();
1199
    $table = clean_scalar($table);
1200
    return { error => 'missing_table' } unless length $table;
1201
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1202

            
1203
    my $quoted = $dbh->quote_identifier($table);
1204
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1205
    my @column_names = map { $_->{name} || '' } @$columns;
1206
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1207
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1208

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

            
1219
sub render_debug_table_csv {
1220
    my ($export) = @_;
1221
    my @columns = @{ $export->{columns} || [] };
1222
    my @lines = (join(',', map { csv_cell($_) } @columns));
1223
    for my $row (@{ $export->{rows} || [] }) {
1224
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1225
    }
1226
    return join("\n", @lines) . "\n";
1227
}
1228

            
1229
sub csv_cell {
1230
    my ($value) = @_;
1231
    $value = '' unless defined $value;
1232
    $value = "$value";
1233
    $value =~ s/"/""/g;
1234
    return qq("$value") if $value =~ /[",\r\n]/;
1235
    return $value;
1236
}
1237

            
1238
sub debug_table_export_filename {
1239
    my ($table, $extension) = @_;
1240
    $table = clean_scalar($table || 'table');
1241
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1242
    $table = 'table' unless length $table;
1243
    return "debug-$table.$extension";
1244
}
1245

            
Bogdan Timofte authored 4 days ago
1246
sub debug_table_exists {
1247
    my ($dbh, $table) = @_;
1248
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1249
    my ($exists) = $dbh->selectrow_array(
1250
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1251
        undef,
1252
        $table,
1253
    );
1254
    return $exists ? 1 : 0;
1255
}
1256

            
1257
sub sum {
1258
    my $total = 0;
1259
    $total += $_ || 0 for @_;
1260
    return $total;
1261
}
1262

            
Xdev Host Manager authored a week ago
1263
sub ca_script_path {
1264
    return "$project_dir/scripts/ca_manager.sh";
1265
}
1266

            
1267
sub ca_dir {
1268
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1269
}
1270

            
1271
sub ca_cert_path {
1272
    return ca_dir() . "/certs/ca.cert.pem";
1273
}
1274

            
Bogdan Timofte authored 5 days ago
1275
sub ca_issued_cert_path {
1276
    my ($name) = @_;
1277
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1278
    return ca_dir() . "/issued/$name.cert.pem";
1279
}
1280

            
Bogdan Timofte authored 4 days ago
1281
sub ca_issued_key_path {
1282
    my ($name) = @_;
1283
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1284
    return ca_dir() . "/issued/$name.key.pem";
1285
}
1286

            
Bogdan Timofte authored 4 days ago
1287
sub ca_private_key_exists {
1288
    my ($name) = @_;
1289
    return 0 unless clean_certificate_id($name || '');
1290
    return -f ca_issued_key_path($name) ? 1 : 0;
1291
}
1292

            
Bogdan Timofte authored 4 days ago
1293
sub ca_manager_output {
1294
    my (@args) = @_;
Xdev Host Manager authored a week ago
1295
    my $script = ca_script_path();
1296
    die "CA manager script is missing\n" unless -x $script;
1297
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 4 days ago
1298
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1299
    local $/;
1300
    my $out = <$fh>;
1301
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
1302
    return $out || '';
1303
}
1304

            
1305
sub ca_manager_json {
1306
    my ($command) = @_;
1307
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1308
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1309
    sync_certificates_from_json($out) if $command eq 'list-json';
1310
    return $out;
1311
}
1312

            
1313
sub sync_certificates_from_json {
1314
    my ($json) = @_;
1315
    my $certs = eval { json_decode($json || '[]') };
1316
    return if $@ || ref($certs) ne 'ARRAY';
1317
    my $dbh = dbh();
1318
    my $now = iso_now();
1319
    with_transaction($dbh, sub {
1320
        for my $cert (@$certs) {
1321
            next unless ref($cert) eq 'HASH';
1322
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1323
            next unless $name;
1324
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1325
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1326
            my $cert_path = ca_issued_cert_path($name);
1327
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1328
            my $serial = clean_scalar($cert->{serial} || '');
1329
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1330
            $dbh->do(
1331
                '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) '
1332
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1333
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1334
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1335
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1336
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1337
                undef,
1338
                $name,
1339
                $host_fqdn || undef,
1340
                $dns_names[0] || '',
1341
                clean_scalar($cert->{subject} || ''),
1342
                clean_scalar($cert->{issuer} || ''),
1343
                length($serial) ? $serial : undef,
1344
                clean_scalar($cert->{not_before} || ''),
1345
                clean_scalar($cert->{not_after} || ''),
1346
                length($fingerprint) ? $fingerprint : undef,
1347
                $cert_path,
1348
                $csr_path,
1349
                $now,
1350
                $now,
1351
            );
1352
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1353
            for my $dns_name (@dns_names) {
1354
                next unless length $dns_name;
1355
                $dbh->do(
1356
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1357
                    undef,
1358
                    $name,
1359
                    $dns_name,
1360
                );
1361
            }
1362
        }
1363
    });
1364
}
1365

            
1366
sub infer_certificate_host_fqdn {
1367
    my ($dbh, $dns_names) = @_;
1368
    for my $name (@$dns_names) {
1369
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1370
        return $fqdn if $fqdn;
1371
    }
1372
    for my $name (@$dns_names) {
1373
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1374
        return $fqdn if $fqdn;
1375
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1376
        return $fqdn if $fqdn;
1377
    }
1378
    return '';
Xdev Host Manager authored a week ago
1379
}
1380

            
Xdev Host Manager authored a week ago
1381
sub parse_hosts_yaml {
1382
    my ($text) = @_;
1383
    my %registry = (
1384
        version => 1,
1385
        updated_at => '',
1386
        policy => {},
1387
        hosts => [],
1388
    );
1389
    my ($section, $current, $list_key);
1390
    for my $line (split /\n/, $text) {
1391
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1392
        if ($line =~ /^version:\s*(\d+)/) {
1393
            $registry{version} = int($1);
1394
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1395
            $registry{updated_at} = yaml_unquote($1);
1396
        } elsif ($line =~ /^policy:\s*$/) {
1397
            $section = 'policy';
1398
        } elsif ($line =~ /^hosts:\s*$/) {
1399
            $section = 'hosts';
1400
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1401
            $registry{policy}{$1} = yaml_unquote($2);
1402
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1403
            $current = {
1404
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1405
                fqdn => '',
Xdev Host Manager authored a week ago
1406
                status => 'active',
Bogdan Timofte authored 4 days ago
1407
                ip => '',
1408
                aliases => [],
1409
                vhosts => [],
Xdev Host Manager authored a week ago
1410
                roles => [],
1411
                sources => [],
1412
                monitoring => 'pending',
1413
                notes => '',
1414
            };
1415
            push @{ $registry{hosts} }, $current;
1416
            $list_key = undef;
1417
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1418
            $list_key = $1;
1419
            $current->{$list_key} ||= [];
1420
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1421
            push @{ $current->{$list_key} }, yaml_unquote($1);
1422
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1423
            my $key = $1;
1424
            my $value = yaml_unquote($2);
1425
            if ($key eq 'ip') {
1426
                $current->{ip} = $value;
1427
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1428
                $current->{ip} ||= $value;
1429
            } elsif ($key eq 'fqdn') {
1430
                $current->{fqdn} = normalize_dns_name($value);
1431
            } elsif ($key eq 'names') {
1432
                # ignored here; legacy list is handled after parsing
1433
            } else {
1434
                $current->{$key} = $value;
1435
            }
Xdev Host Manager authored a week ago
1436
            $list_key = undef;
1437
        }
1438
    }
Bogdan Timofte authored 4 days ago
1439
    for my $host (@{ $registry{hosts} }) {
1440
        my @legacy_names = @{ $host->{names} || [] };
1441
        if (@legacy_names) {
1442
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1443
            $host->{fqdn} ||= $legacy->{fqdn};
1444
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1445
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1446
        }
1447
        delete $host->{names};
1448
        $host->{fqdn} ||= canonical_host_fqdn($host);
1449
    }
Xdev Host Manager authored a week ago
1450
    return \%registry;
1451
}
1452

            
1453
sub render_hosts_yaml {
1454
    my ($registry) = @_;
1455
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1456
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1457
    $out .= "policy:\n";
1458
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1459
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1460
    }
1461
    $out .= "hosts:\n";
1462
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1463
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1464
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1465
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1466
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1467
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1468
            $out .= "    $key:\n";
1469
            for my $value (@{ $host->{$key} || [] }) {
1470
                $out .= "      - " . yq($value) . "\n";
1471
            }
1472
        }
1473
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1474
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1475
    }
1476
    return $out;
1477
}
1478

            
Xdev Host Manager authored a week ago
1479
sub parse_work_orders_yaml {
1480
    my ($text) = @_;
1481
    my %orders = (
1482
        version => 1,
1483
        work_orders => [],
1484
    );
Xdev Host Manager authored a week ago
1485
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1486
    for my $line (split /\n/, $text) {
1487
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1488
        if ($line =~ /^version:\s*(\d+)/) {
1489
            $orders{version} = int($1);
1490
        } elsif ($line =~ /^work_orders:\s*$/) {
1491
            $section = 'work_orders';
1492
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1493
            $current = {
1494
                id => yaml_unquote($1),
1495
                status => 'pending',
Xdev Host Manager authored a week ago
1496
                checklist => [],
Xdev Host Manager authored a week ago
1497
                actions => [],
1498
            };
1499
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1500
            $list_section = '';
Xdev Host Manager authored a week ago
1501
            $current_action = undef;
Xdev Host Manager authored a week ago
1502
            $current_item = undef;
1503
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1504
            $list_section = 'checklist';
1505
            $current->{checklist} ||= [];
1506
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1507
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1508
            push @{ $current->{checklist} }, $current_item;
1509
            $current_action = undef;
1510
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1511
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1512
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1513
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1514
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1515
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1516
            $current_action = { type => yaml_unquote($1) };
1517
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1518
            $current_item = undef;
1519
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1520
            $current_action->{$1} = yaml_unquote($2);
1521
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1522
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1523
            $list_section = '';
Xdev Host Manager authored a week ago
1524
            $current_action = undef;
Xdev Host Manager authored a week ago
1525
            $current_item = undef;
Xdev Host Manager authored a week ago
1526
        }
1527
    }
1528
    return \%orders;
1529
}
1530

            
1531
sub render_work_orders_yaml {
1532
    my ($orders) = @_;
1533
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1534
    $out .= "work_orders:\n";
1535
    for my $wo (@{ $orders->{work_orders} || [] }) {
1536
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1537
        for my $key (qw(status title reason created_at confirmed_at result)) {
1538
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1539
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1540
        }
Xdev Host Manager authored a week ago
1541
        $out .= "    checklist:\n";
1542
        for my $item (@{ $wo->{checklist} || [] }) {
1543
            $out .= "      - id: " . yq($item->{id}) . "\n";
1544
            for my $key (qw(text status owner notes updated_at)) {
1545
                next unless exists $item->{$key} && length($item->{$key} || '');
1546
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1547
            }
1548
        }
Xdev Host Manager authored a week ago
1549
        $out .= "    actions:\n";
1550
        for my $action (@{ $wo->{actions} || [] }) {
1551
            $out .= "      - type: " . yq($action->{type}) . "\n";
1552
            for my $key (qw(host_id name)) {
1553
                next unless exists $action->{$key} && length($action->{$key} || '');
1554
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1555
            }
1556
        }
1557
    }
1558
    return $out;
1559
}
1560

            
Xdev Host Manager authored a week ago
1561
sub request_payload {
1562
    my ($headers, $body) = @_;
1563
    my $type = $headers->{'content-type'} || '';
1564
    if ($type =~ m{application/json}) {
1565
        return json_decode($body || '{}');
1566
    }
1567
    return { parse_params($body || '') };
1568
}
1569

            
1570
sub json_bool {
1571
    my ($value) = @_;
1572
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1573
}
1574

            
1575
sub json_encode {
1576
    my ($value) = @_;
1577
    if (!defined $value) {
1578
        return 'null';
1579
    }
1580
    my $ref = ref($value);
1581
    if (!$ref) {
1582
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1583
        return json_string($value);
1584
    }
1585
    if ($ref eq 'HostManager::JSONBool') {
1586
        return $$value ? 'true' : 'false';
1587
    }
1588
    if ($ref eq 'ARRAY') {
1589
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1590
    }
1591
    if ($ref eq 'HASH') {
1592
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1593
    }
1594
    return json_string("$value");
1595
}
1596

            
1597
sub json_string {
1598
    my ($value) = @_;
1599
    $value = '' unless defined $value;
1600
    $value =~ s/\\/\\\\/g;
1601
    $value =~ s/"/\\"/g;
1602
    $value =~ s/\n/\\n/g;
1603
    $value =~ s/\r/\\r/g;
1604
    $value =~ s/\t/\\t/g;
1605
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1606
    return qq("$value");
1607
}
1608

            
1609
sub json_decode {
1610
    my ($text) = @_;
1611
    my $i = 0;
1612
    my $len = length($text);
1613
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1614

            
1615
    $skip_ws = sub {
1616
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1617
    };
1618

            
1619
    $parse_string = sub {
1620
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1621
        $i++;
1622
        my $out = '';
1623
        while ($i < $len) {
1624
            my $ch = substr($text, $i++, 1);
1625
            return $out if $ch eq '"';
1626
            if ($ch eq "\\") {
1627
                die "Bad JSON escape\n" if $i >= $len;
1628
                my $esc = substr($text, $i++, 1);
1629
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1630
                    $out .= $esc;
1631
                } elsif ($esc eq 'b') {
1632
                    $out .= "\b";
1633
                } elsif ($esc eq 'f') {
1634
                    $out .= "\f";
1635
                } elsif ($esc eq 'n') {
1636
                    $out .= "\n";
1637
                } elsif ($esc eq 'r') {
1638
                    $out .= "\r";
1639
                } elsif ($esc eq 't') {
1640
                    $out .= "\t";
1641
                } elsif ($esc eq 'u') {
1642
                    my $hex = substr($text, $i, 4);
1643
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1644
                    $out .= chr(hex($hex));
1645
                    $i += 4;
1646
                } else {
1647
                    die "Bad JSON escape\n";
1648
                }
1649
            } else {
1650
                $out .= $ch;
1651
            }
1652
        }
1653
        die "Unterminated JSON string\n";
1654
    };
1655

            
1656
    $parse_number = sub {
1657
        my $start = $i;
1658
        $i++ if substr($text, $i, 1) eq '-';
1659
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1660
        if ($i < $len && substr($text, $i, 1) eq '.') {
1661
            $i++;
1662
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1663
        }
1664
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1665
            $i++;
1666
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1667
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1668
        }
1669
        return 0 + substr($text, $start, $i - $start);
1670
    };
1671

            
1672
    $parse_array = sub {
1673
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1674
        $i++;
1675
        my @out;
1676
        $skip_ws->();
1677
        if ($i < $len && substr($text, $i, 1) eq ']') {
1678
            $i++;
1679
            return \@out;
1680
        }
1681
        while (1) {
1682
            push @out, $parse_value->();
1683
            $skip_ws->();
1684
            my $ch = substr($text, $i++, 1);
1685
            last if $ch eq ']';
1686
            die "Expected JSON array comma\n" unless $ch eq ',';
1687
        }
1688
        return \@out;
1689
    };
1690

            
1691
    $parse_object = sub {
1692
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1693
        $i++;
1694
        my %out;
1695
        $skip_ws->();
1696
        if ($i < $len && substr($text, $i, 1) eq '}') {
1697
            $i++;
1698
            return \%out;
1699
        }
1700
        while (1) {
1701
            $skip_ws->();
1702
            my $key = $parse_string->();
1703
            $skip_ws->();
1704
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1705
            $out{$key} = $parse_value->();
1706
            $skip_ws->();
1707
            my $ch = substr($text, $i++, 1);
1708
            last if $ch eq '}';
1709
            die "Expected JSON object comma\n" unless $ch eq ',';
1710
        }
1711
        return \%out;
1712
    };
1713

            
1714
    $parse_value = sub {
1715
        $skip_ws->();
1716
        die "Unexpected end of JSON\n" if $i >= $len;
1717
        my $ch = substr($text, $i, 1);
1718
        return $parse_string->() if $ch eq '"';
1719
        return $parse_object->() if $ch eq '{';
1720
        return $parse_array->() if $ch eq '[';
1721
        if (substr($text, $i, 4) eq 'true') {
1722
            $i += 4;
1723
            return json_bool(1);
1724
        }
1725
        if (substr($text, $i, 5) eq 'false') {
1726
            $i += 5;
1727
            return json_bool(0);
1728
        }
1729
        if (substr($text, $i, 4) eq 'null') {
1730
            $i += 4;
1731
            return undef;
1732
        }
1733
        return $parse_number->() if $ch =~ /[-0-9]/;
1734
        die "Unexpected JSON token\n";
1735
    };
1736

            
1737
    my $value = $parse_value->();
1738
    $skip_ws->();
1739
    die "Trailing JSON content\n" if $i != $len;
1740
    return $value;
1741
}
1742

            
1743
sub parse_params {
1744
    my ($text) = @_;
1745
    my %out;
1746
    for my $pair (split /&/, $text) {
1747
        next unless length $pair;
1748
        my ($k, $v) = split /=/, $pair, 2;
1749
        $out{url_decode($k)} = url_decode($v || '');
1750
    }
1751
    return %out;
1752
}
1753

            
1754
sub clean_id {
1755
    my ($value) = @_;
1756
    $value = lc clean_scalar($value);
1757
    $value =~ s/[^a-z0-9_.-]+/-/g;
1758
    $value =~ s/^-+|-+$//g;
1759
    return $value;
1760
}
1761

            
Bogdan Timofte authored 4 days ago
1762
sub clean_certificate_id {
1763
    my ($value) = @_;
1764
    $value = clean_scalar($value);
1765
    return '' unless length $value;
1766
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1767
}
1768

            
Xdev Host Manager authored a week ago
1769
sub clean_scalar {
1770
    my ($value) = @_;
1771
    $value = '' unless defined $value;
1772
    $value =~ s/[\r\n\t]+/ /g;
1773
    $value =~ s/^\s+|\s+$//g;
1774
    return $value;
1775
}
1776

            
1777
sub clean_list {
1778
    my ($value) = @_;
1779
    return () unless defined $value;
1780
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1781
    my @clean;
1782
    for my $item (@items) {
1783
        $item = clean_scalar($item);
1784
        push @clean, $item if length $item;
1785
    }
1786
    return @clean;
1787
}
1788

            
1789
sub yq {
1790
    my ($value) = @_;
1791
    $value = '' unless defined $value;
1792
    $value =~ s/\\/\\\\/g;
1793
    $value =~ s/"/\\"/g;
1794
    return qq("$value");
1795
}
1796

            
1797
sub yaml_unquote {
1798
    my ($value) = @_;
1799
    $value = '' unless defined $value;
1800
    $value =~ s/^\s+|\s+$//g;
1801
    if ($value =~ /^"(.*)"$/) {
1802
        $value = $1;
1803
        $value =~ s/\\"/"/g;
1804
        $value =~ s/\\\\/\\/g;
1805
    }
1806
    return $value;
1807
}
1808

            
1809
sub verify_totp {
1810
    my ($secret, $otp) = @_;
1811
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1812
    my $key = eval { base32_decode($secret) };
1813
    return 0 if $@ || !length $key;
1814
    my $counter = int(time() / 30);
1815
    for my $offset (-1, 0, 1) {
1816
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1817
    }
1818
    return 0;
1819
}
1820

            
1821
sub totp_code {
1822
    my ($key, $counter) = @_;
1823
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1824
    my $hash = hmac_sha1($msg, $key);
1825
    my $offset = ord(substr($hash, -1)) & 0x0f;
1826
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1827
    return sprintf('%06d', $bin % 1_000_000);
1828
}
1829

            
1830
sub base32_decode {
1831
    my ($text) = @_;
1832
    $text = uc($text || '');
1833
    $text =~ s/[^A-Z2-7]//g;
1834
    my %map;
1835
    my @chars = ('A'..'Z', '2'..'7');
1836
    @map{@chars} = (0..31);
1837
    my ($bits, $value, $out) = (0, 0, '');
1838
    for my $char (split //, $text) {
1839
        die "Invalid base32\n" unless exists $map{$char};
1840
        $value = ($value << 5) | $map{$char};
1841
        $bits += 5;
1842
        while ($bits >= 8) {
1843
            $bits -= 8;
1844
            $out .= chr(($value >> $bits) & 0xff);
1845
        }
1846
    }
1847
    return $out;
1848
}
1849

            
1850
sub create_session {
1851
    my $nonce = random_hex(24);
1852
    my $expires = int(time() + 8 * 3600);
1853
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1854
    my $token = "$nonce:$expires:$sig";
1855
    $sessions{$token} = $expires;
1856
    return $token;
1857
}
1858

            
1859
sub is_authenticated {
1860
    my ($headers) = @_;
1861
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1862
    return 0 unless $token;
1863
    my ($nonce, $expires, $sig) = split /:/, $token;
1864
    return 0 unless $nonce && $expires && $sig;
1865
    return 0 if $expires < time();
1866
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1867
    return exists $sessions{$token};
1868
}
1869

            
1870
sub expire_session {
1871
    my ($headers) = @_;
1872
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1873
    delete $sessions{$token} if $token;
1874
}
1875

            
1876
sub cookie_value {
1877
    my ($cookie, $name) = @_;
1878
    for my $part (split /;\s*/, $cookie) {
1879
        my ($k, $v) = split /=/, $part, 2;
1880
        return $v if defined $k && $k eq $name;
1881
    }
1882
    return '';
1883
}
1884

            
1885
sub send_json {
1886
    my ($client, $status, $payload, $extra_headers) = @_;
1887
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1888
}
1889

            
Xdev Host Manager authored a week ago
1890
sub send_json_raw {
1891
    my ($client, $status, $json_body, $extra_headers) = @_;
1892
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1893
}
1894

            
Xdev Host Manager authored a week ago
1895
sub send_html {
1896
    my ($client, $status, $html) = @_;
1897
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1898
}
1899

            
1900
sub send_text {
1901
    my ($client, $status, $text) = @_;
1902
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1903
}
1904

            
1905
sub send_download {
1906
    my ($client, $status, $content, $type, $filename) = @_;
1907
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1908
}
1909

            
1910
sub send_file {
1911
    my ($client, $path, $type, $filename) = @_;
1912
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1913
    return send_download($client, 200, read_file($path), $type, $filename);
1914
}
1915

            
1916
sub send_response {
1917
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1918
    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
1919
    $body = '' unless defined $body;
1920
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1921
    print $client "Content-Type: $type\r\n";
1922
    print $client "Content-Length: " . length($body) . "\r\n";
1923
    print $client "Cache-Control: no-store\r\n";
1924
    print $client "$_\r\n" for @{ $extra_headers || [] };
1925
    print $client "Connection: close\r\n\r\n";
1926
    print $client $body;
1927
}
1928

            
1929
sub read_file {
1930
    my ($path) = @_;
1931
    open my $fh, '<', $path or die "Cannot read $path: $!";
1932
    local $/;
1933
    return <$fh>;
1934
}
1935

            
1936
sub write_file {
1937
    my ($path, $content) = @_;
1938
    open my $fh, '>', $path or die "Cannot write $path: $!";
1939
    print {$fh} $content;
1940
    close $fh or die "Cannot close $path: $!";
1941
}
1942

            
1943
sub backup_file {
1944
    my ($path) = @_;
1945
    return unless -f $path;
1946
    my $backup_dir = "$project_dir/backups/host-manager";
1947
    make_path($backup_dir) unless -d $backup_dir;
1948
    my $name = $path;
1949
    $name =~ s{.*/}{};
1950
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1951
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1952
}
1953

            
Bogdan Timofte authored 4 days ago
1954
my $db_handle;
Bogdan Timofte authored 4 days ago
1955
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1956

            
1957
sub dbh {
1958
    return $db_handle if $db_handle;
1959
    ensure_parent_dir($opt{db});
1960
    $db_handle = DBI->connect(
1961
        "dbi:SQLite:dbname=$opt{db}",
1962
        '',
1963
        '',
1964
        {
1965
            RaiseError => 1,
1966
            PrintError => 0,
1967
            AutoCommit => 1,
1968
            sqlite_unicode => 1,
1969
        },
1970
    ) or die "Cannot open SQLite database $opt{db}\n";
1971
    $db_handle->do('PRAGMA journal_mode = WAL');
1972
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1973
    create_database_schema($db_handle);
1974
    seed_database($db_handle) unless $db_seeded++;
1975
    return $db_handle;
1976
}
1977

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

            
Bogdan Timofte authored 4 days ago
2253
sub seed_database {
2254
    my ($dbh) = @_;
2255
    seed_default_workers($dbh);
2256

            
2257
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2258
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2259
        normalize_registry_policy($registry);
2260
        with_transaction($dbh, sub {
2261
            import_registry_to_db($dbh, $registry, 0);
2262
        });
2263
    }
2264

            
2265
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2266
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2267
        with_transaction($dbh, sub {
2268
            import_work_orders_to_db($dbh, $orders);
2269
        });
2270
    }
2271

            
2272
    seed_mdns_observations_from_yaml($dbh);
2273
}
2274

            
2275
sub with_transaction {
2276
    my ($dbh, $code) = @_;
2277
    return $code->() unless $dbh->{AutoCommit};
2278
    $dbh->begin_work;
2279
    my $ok = eval {
2280
        $code->();
2281
        1;
2282
    };
2283
    if (!$ok) {
2284
        my $err = $@ || 'transaction failed';
2285
        eval { $dbh->rollback };
2286
        die $err;
2287
    }
2288
    $dbh->commit;
2289
}
2290

            
2291
sub db_scalar {
2292
    my ($dbh, $sql, @bind) = @_;
2293
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2294
    return $value || 0;
2295
}
2296

            
2297
sub legacy_document_text {
2298
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2299
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2300
    return $row->{content} if $row && defined $row->{content};
2301
    return read_file($seed_path) if -f $seed_path;
2302
    return $default_text;
2303
}
2304

            
2305
sub load_registry_from_db {
2306
    my $dbh = dbh();
2307
    my $registry = {
2308
        version => 1,
2309
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2310
        policy => {},
2311
        hosts => [],
2312
    };
Bogdan Timofte authored 4 days ago
2313

            
Bogdan Timofte authored 4 days ago
2314
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2315
    $sth->execute;
2316
    while (my $row = $sth->fetchrow_hashref) {
2317
        my $fqdn = $row->{fqdn};
2318
        push @{ $registry->{hosts} }, {
2319
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2320
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2321
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2322
            ip => canonical_ip($row),
2323
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2324
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2325
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2326
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2327
            monitoring => $row->{monitoring},
2328
            notes => $row->{notes},
2329
        };
2330
    }
2331

            
2332
    return $registry;
Bogdan Timofte authored 4 days ago
2333
}
2334

            
Bogdan Timofte authored 4 days ago
2335
sub save_registry_to_db {
2336
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2337
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2338
    with_transaction($dbh, sub {
2339
        import_registry_to_db($dbh, $registry, 1);
2340
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2341
    });
2342
}
2343

            
2344
sub import_registry_to_db {
2345
    my ($dbh, $registry, $retire_missing) = @_;
2346
    my %seen;
2347
    for my $host (@{ $registry->{hosts} || [] }) {
2348
        my $fqdn = upsert_host_to_db($dbh, $host);
2349
        $seen{$fqdn} = 1 if $fqdn;
2350
    }
2351

            
2352
    return unless $retire_missing;
2353
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2354
    $sth->execute('retired');
2355
    while (my ($fqdn) = $sth->fetchrow_array) {
2356
        next if $seen{$fqdn};
2357
        retire_host_in_db($dbh, $fqdn);
2358
    }
2359
}
2360

            
2361
sub upsert_host_to_db {
2362
    my ($dbh, $host) = @_;
2363
    my $now = iso_now();
2364
    my $fqdn = canonical_host_fqdn($host);
2365
    return '' unless $fqdn;
2366
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2367
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2368
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2369
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2370
    my $notes = clean_scalar($host->{notes} || '');
2371

            
Bogdan Timofte authored 4 days ago
2372
    $dbh->do(
Bogdan Timofte authored 4 days ago
2373
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2374
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2375
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2376
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2377
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2378
        undef,
Bogdan Timofte authored 4 days ago
2379
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2380
    );
2381

            
2382
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2383
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2384
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2385
    return $fqdn;
2386
}
2387

            
Bogdan Timofte authored 4 days ago
2388
sub upsert_host_tls_row {
2389
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2390
    $certificate_id = clean_certificate_id($certificate_id || '');
2391
    $dbh->do(
2392
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2393
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2394
        undef,
2395
        $host_fqdn,
2396
        length($certificate_id) ? 'local-ca' : 'none',
2397
        length($certificate_id) ? $certificate_id : undef,
2398
        '',
2399
        $now,
2400
        $now,
2401
    );
2402
}
2403

            
Bogdan Timofte authored 4 days ago
2404
sub sync_host_values {
2405
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2406
    my $now = iso_now();
2407
    my %active = map { $_ => 1 } @$values;
2408
    for my $value (@$values) {
2409
        $dbh->do(
2410
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2411
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2412
            undef,
2413
            $fqdn, $value, $now,
2414
        );
2415
    }
2416

            
2417
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2418
    $sth->execute($fqdn);
2419
    while (my ($value) = $sth->fetchrow_array) {
2420
        next if $active{$value};
2421
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2422
    }
2423
}
2424

            
Bogdan Timofte authored 4 days ago
2425
sub sync_host_aliases_and_vhosts {
2426
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2427
    my $now = iso_now();
2428
    my (%aliases, %vhosts);
2429
    if (my $short = short_alias_for_fqdn($fqdn)) {
2430
        $aliases{$short} = 1;
2431
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2432
    }
Bogdan Timofte authored 4 days ago
2433
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2434
        $name = normalize_dns_name($name);
2435
        next unless length $name;
2436
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2437
        $aliases{$name} = 1;
2438
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2439
        if (my $short = short_alias_for_fqdn($name)) {
2440
            $aliases{$short} = 1;
2441
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2442
        }
2443
    }
2444
    for my $name (@$vhosts_in) {
2445
        $name = normalize_dns_name($name);
2446
        next unless length $name;
2447
        $vhosts{$name} = 1;
2448
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2449
        if (my $short = short_alias_for_fqdn($name)) {
2450
            $aliases{$short} = 1;
2451
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2452
        }
2453
    }
2454

            
2455
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2456
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2457
}
2458

            
2459
sub upsert_alias_to_db {
2460
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2461
    my ($existing_fqdn) = $dbh->selectrow_array(
2462
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2463
        undef,
2464
        $alias,
2465
    );
2466
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2467
        if ($kind eq 'derived-vhost') {
2468
            $dbh->do(
2469
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2470
                undef,
2471
                $now, $alias, $existing_fqdn,
2472
            );
2473
        } else {
2474
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2475
        }
2476
    }
Bogdan Timofte authored 4 days ago
2477
    $dbh->do(
2478
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2479
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2480
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2481
        undef,
2482
        $alias, $fqdn, $kind, $now,
2483
    );
2484
}
2485

            
2486
sub upsert_vhost_to_db {
2487
    my ($dbh, $fqdn, $vhost, $now) = @_;
2488
    my $service = vhost_service_name($vhost);
2489
    $dbh->do(
2490
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2491
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2492
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2493
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2494
        undef,
2495
        $vhost, $fqdn, $service, $now, $now,
2496
    );
2497
}
2498

            
2499
sub retire_missing_names {
2500
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2501
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2502
    $sth->execute($fqdn);
2503
    while (my ($name) = $sth->fetchrow_array) {
2504
        next if $active->{$name};
2505
        if ($table eq 'host_aliases') {
2506
            $dbh->do(
2507
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2508
                undef, $now, $fqdn, $name,
2509
            );
2510
        } else {
2511
            $dbh->do(
2512
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2513
                undef, $now, $fqdn, $name,
2514
            );
2515
        }
2516
    }
2517
}
2518

            
2519
sub retire_host_in_db {
2520
    my ($dbh, $fqdn) = @_;
2521
    my $now = iso_now();
2522
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2523
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2524
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2525
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2526
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2527
}
2528

            
Bogdan Timofte authored 4 days ago
2529
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2530
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2531
    my @names;
Bogdan Timofte authored 4 days ago
2532
    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");
2533
    $aliases->execute($fqdn);
2534
    while (my ($name) = $aliases->fetchrow_array) {
2535
        push @names, $name;
2536
    }
Bogdan Timofte authored 4 days ago
2537
    return unique_preserve(@names);
2538
}
2539

            
2540
sub active_vhosts_for_host {
2541
    my ($dbh, $fqdn) = @_;
2542
    my @names;
Bogdan Timofte authored 4 days ago
2543
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2544
    $vhosts->execute($fqdn);
2545
    while (my ($name) = $vhosts->fetchrow_array) {
2546
        push @names, $name;
2547
    }
2548
    return unique_preserve(@names);
2549
}
2550

            
2551
sub active_values_for_host {
2552
    my ($dbh, $table, $column, $fqdn) = @_;
2553
    my @values;
2554
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2555
    $sth->execute($fqdn);
2556
    while (my ($value) = $sth->fetchrow_array) {
2557
        push @values, $value;
2558
    }
2559
    return @values;
2560
}
2561

            
2562
sub load_work_orders_from_db {
2563
    my $dbh = dbh();
2564
    my $orders = { version => 1, work_orders => [] };
2565
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2566
    $sth->execute;
2567
    while (my $row = $sth->fetchrow_hashref) {
2568
        my $wo = {
2569
            id => $row->{id},
2570
            status => $row->{status},
2571
            title => $row->{title},
2572
            reason => $row->{reason},
2573
            created_at => $row->{created_at},
2574
            checklist => [],
2575
            actions => [],
2576
        };
2577
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2578
        $wo->{result} = $row->{result} if length($row->{result} || '');
2579

            
2580
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2581
        $items->execute($row->{id});
2582
        while (my $item = $items->fetchrow_hashref) {
2583
            my %copy = (
2584
                id => $item->{item_id},
2585
                text => $item->{text},
2586
                status => $item->{status},
2587
            );
2588
            for my $key (qw(owner notes updated_at)) {
2589
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2590
            }
2591
            push @{ $wo->{checklist} }, \%copy;
2592
        }
2593

            
2594
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2595
        $actions->execute($row->{id});
2596
        while (my $action = $actions->fetchrow_hashref) {
2597
            my %copy = ( type => $action->{type} );
2598
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2599
            $copy{name} = $action->{name} if length($action->{name} || '');
2600
            push @{ $wo->{actions} }, \%copy;
2601
        }
2602

            
2603
        push @{ $orders->{work_orders} }, $wo;
2604
    }
2605
    return $orders;
2606
}
2607

            
2608
sub save_work_orders_to_db {
2609
    my ($orders) = @_;
2610
    my $dbh = dbh();
2611
    with_transaction($dbh, sub {
2612
        import_work_orders_to_db($dbh, $orders);
2613
    });
2614
}
2615

            
2616
sub import_work_orders_to_db {
2617
    my ($dbh, $orders) = @_;
2618
    my $now = iso_now();
2619
    my %seen;
2620
    for my $wo (@{ $orders->{work_orders} || [] }) {
2621
        my $id = clean_scalar($wo->{id} || '');
2622
        next unless $id;
2623
        $seen{$id} = 1;
2624
        $dbh->do(
2625
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2626
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2627
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2628
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2629
            undef,
2630
            $id,
2631
            clean_scalar($wo->{status} || 'pending'),
2632
            clean_scalar($wo->{title} || ''),
2633
            clean_scalar($wo->{reason} || ''),
2634
            clean_scalar($wo->{created_at} || $now),
2635
            clean_scalar($wo->{confirmed_at} || ''),
2636
            clean_scalar($wo->{result} || ''),
2637
            $now,
2638
        );
2639
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2640
        for my $item (@{ $wo->{checklist} || [] }) {
2641
            $dbh->do(
2642
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2643
                undef,
2644
                $id,
2645
                clean_scalar($item->{id} || ''),
2646
                clean_scalar($item->{text} || ''),
2647
                clean_scalar($item->{status} || 'pending'),
2648
                clean_scalar($item->{owner} || ''),
2649
                clean_scalar($item->{notes} || ''),
2650
                clean_scalar($item->{updated_at} || ''),
2651
            );
2652
        }
2653
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2654
        my $position = 0;
2655
        for my $action (@{ $wo->{actions} || [] }) {
2656
            my $legacy_id = clean_id($action->{host_id} || '');
2657
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2658
            $dbh->do(
2659
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2660
                undef,
2661
                $id,
2662
                $position++,
2663
                clean_scalar($action->{type} || ''),
2664
                $host_fqdn || undef,
2665
                $legacy_id,
2666
                normalize_dns_name($action->{name} || ''),
2667
                '',
2668
            );
2669
        }
2670
    }
2671
}
2672

            
2673
sub seed_default_workers {
2674
    my ($dbh) = @_;
2675
    my $now = iso_now();
2676
    my @workers = (
2677
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2678
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2679
    );
2680
    for my $worker (@workers) {
2681
        $dbh->do(
2682
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2683
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2684
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2685
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2686
            undef,
2687
            @$worker,
2688
            $now,
2689
            $now,
2690
        );
2691
    }
2692
}
2693

            
2694
sub seed_mdns_observations_from_yaml {
2695
    my ($dbh) = @_;
2696
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2697
    my $path = "$project_dir/var/mdns-observations.yaml";
2698
    return unless -f $path;
2699
    my $db = parse_mdns_observations_yaml(read_file($path));
2700
    with_transaction($dbh, sub {
2701
        for my $observation (@{ $db->{observations} || [] }) {
2702
            $dbh->do(
2703
                '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) '
2704
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2705
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2706
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2707
                undef,
2708
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2709
                clean_scalar($observation->{name} || ''),
2710
                clean_scalar($observation->{ip} || ''),
2711
                int($observation->{ttl} || 0),
2712
                clean_scalar($observation->{first_seen} || iso_now()),
2713
                clean_scalar($observation->{last_seen} || iso_now()),
2714
                int($observation->{seen_count} || 1),
2715
                clean_scalar($observation->{last_peer} || ''),
2716
            );
2717
        }
2718
    });
2719
}
2720

            
2721
sub parse_mdns_observations_yaml {
2722
    my ($text) = @_;
2723
    my %db = ( observations => [] );
2724
    my ($section, $current);
2725
    for my $line (split /\n/, $text || '') {
2726
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2727
        if ($line =~ /^observations:\s*$/) {
2728
            $section = 'observations';
2729
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2730
            $current = { key => yaml_unquote($1) };
2731
            push @{ $db{observations} }, $current;
2732
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2733
            $current->{$1} = yaml_unquote($2);
2734
        }
2735
    }
2736
    return \%db;
2737
}
2738

            
2739
sub set_schema_meta {
2740
    my ($dbh, $key, $value) = @_;
2741
    $dbh->do(
2742
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2743
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2744
        undef,
2745
        $key,
2746
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2747
        iso_now(),
2748
    );
2749
}
2750

            
Bogdan Timofte authored 4 days ago
2751
sub fqdn_for_legacy_id {
2752
    my ($dbh, $legacy_id) = @_;
2753
    return '' unless length($legacy_id || '');
2754
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2755
    return $fqdn || '';
2756
}
2757

            
2758
sub canonical_host_fqdn {
2759
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2760
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2761
    return $fqdn if length $fqdn;
2762
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2763
    for my $name (@names) {
2764
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2765
    }
2766
    for my $name (@names) {
2767
        return $name if $name =~ /\./ && !name_is_vhost($name);
2768
    }
2769
    my $id = clean_id($host->{id} || '');
2770
    return $id ? "$id.madagascar.xdev.ro" : '';
2771
}
2772

            
2773
sub legacy_id_from_fqdn {
2774
    my ($fqdn) = @_;
2775
    $fqdn = normalize_dns_name($fqdn);
2776
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2777
    $fqdn =~ s/\..*\z//;
2778
    return clean_id($fqdn);
2779
}
2780

            
2781
sub normalize_dns_name {
2782
    my ($name) = @_;
2783
    $name = lc clean_scalar($name || '');
2784
    $name =~ s/\.\z//;
2785
    return $name;
2786
}
2787

            
2788
sub name_is_vhost {
2789
    my ($name) = @_;
2790
    $name = normalize_dns_name($name);
2791
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2792
}
2793

            
2794
sub vhost_service_name {
2795
    my ($name) = @_;
2796
    $name = normalize_dns_name($name);
2797
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2798
    return '';
2799
}
2800

            
2801
sub short_alias_for_fqdn {
2802
    my ($name) = @_;
2803
    $name = normalize_dns_name($name);
2804
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2805
    return '';
2806
}
2807

            
Bogdan Timofte authored 4 days ago
2808
sub normalize_registry_policy {
2809
    my ($registry) = @_;
2810
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2811
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2812
    $registry->{policy}{runtime_database} = $opt{db};
2813
}
2814

            
2815
sub default_hosts_yaml {
2816
    return <<'YAML';
2817
version: 1
2818
updated_at: ""
2819
policy:
Bogdan Timofte authored 4 days ago
2820
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2821
hosts:
2822
YAML
2823
}
2824

            
2825
sub default_work_orders_yaml {
2826
    return <<'YAML';
2827
version: 1
2828
work_orders:
2829
YAML
2830
}
2831

            
2832
sub ensure_parent_dir {
2833
    my ($path) = @_;
2834
    my $dir = dirname($path);
2835
    make_path($dir) unless -d $dir;
2836
}
2837

            
Xdev Host Manager authored a week ago
2838
sub url_decode {
2839
    my ($value) = @_;
2840
    $value = '' unless defined $value;
2841
    $value =~ tr/+/ /;
2842
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2843
    return $value;
2844
}
2845

            
2846
sub random_hex {
2847
    my ($bytes) = @_;
2848
    if (open my $fh, '<:raw', '/dev/urandom') {
2849
        read($fh, my $raw, $bytes);
2850
        close $fh;
2851
        return unpack('H*', $raw);
2852
    }
2853
    return sha256_hex(rand() . time() . $$);
2854
}
2855

            
2856
sub iso_now {
2857
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2858
}
2859

            
Bogdan Timofte authored 6 days ago
2860
sub build_info {
2861
    my %info = (
2862
        revision => '',
2863
        branch => '',
2864
        built_at => '',
2865
        deployed_at => '',
2866
        dirty => '',
2867
    );
2868

            
2869
    if ($ENV{HOST_MANAGER_BUILD}) {
2870
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2871
        return \%info;
2872
    }
2873

            
2874
    my $build_file = "$project_dir/BUILD";
2875
    if (-f $build_file) {
2876
        for my $line (split /\n/, read_file($build_file)) {
2877
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2878
            $info{$1} = clean_scalar($2);
2879
        }
2880
        return \%info if $info{revision} || $info{built_at};
2881
    }
2882

            
2883
    my $revision = git_value('rev-parse --short=12 HEAD');
2884
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2885
    $info{revision} = $revision if $revision;
2886
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2887
    return \%info;
2888
}
2889

            
2890
sub git_value {
2891
    my ($args) = @_;
2892
    return '' unless -d "$project_dir/.git";
2893
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2894
    my $value = <$fh> || '';
2895
    close $fh;
2896
    chomp $value;
2897
    return clean_scalar($value);
2898
}
2899

            
2900
sub build_label {
2901
    my $info = build_info();
2902
    my $revision = $info->{revision} || 'unknown';
2903
    my $branch = $info->{branch} || '';
2904
    $branch = '' if $branch eq 'HEAD';
2905
    my $label = $branch ? "$branch $revision" : $revision;
2906
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2907
    return $label;
2908
}
2909

            
2910
sub build_title {
2911
    my $info = build_info();
2912
    my $label = build_label();
2913
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2914
    return $stamp ? "$label deployed $stamp" : $label;
2915
}
2916

            
Bogdan Timofte authored 4 days ago
2917
sub build_revision {
2918
    my $info = build_info();
2919
    return $info->{revision} || 'unknown';
2920
}
2921

            
2922
sub build_details {
2923
    my $info = build_info();
2924
    my %details = (
2925
        app => 'Madagascar Local Authority',
2926
        revision => $info->{revision} || 'unknown',
2927
        branch => $info->{branch} || '',
2928
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2929
        built_at => $info->{built_at} || '',
2930
        deployed_at => $info->{deployed_at} || '',
2931
        label => build_label(),
2932
        title => build_title(),
2933
    );
2934
    return json_encode(\%details);
2935
}
2936

            
Bogdan Timofte authored 6 days ago
2937
sub html_escape {
2938
    my ($value) = @_;
2939
    $value = '' unless defined $value;
2940
    $value =~ s/&/&amp;/g;
2941
    $value =~ s/</&lt;/g;
2942
    $value =~ s/>/&gt;/g;
2943
    $value =~ s/"/&quot;/g;
2944
    $value =~ s/'/&#039;/g;
2945
    return $value;
2946
}
2947

            
Xdev Host Manager authored a week ago
2948
sub app_html {
Bogdan Timofte authored 4 days ago
2949
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2950
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2951
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2952
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2953
<!doctype html>
2954
<html lang="ro">
2955
<head>
2956
  <meta charset="utf-8">
2957
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2958
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2959
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2960
  <style>
2961
    :root {
2962
      color-scheme: light;
2963
      --ink: #152033;
2964
      --muted: #647084;
2965
      --line: #d8dee8;
2966
      --soft: #f4f6f9;
2967
      --panel: #ffffff;
2968
      --accent: #1267d8;
2969
      --bad: #b42318;
2970
      --warn: #946200;
2971
      --ok: #137333;
2972
    }
2973
    * { box-sizing: border-box; }
2974
    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
2975

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

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

            
Xdev Host Manager authored a week ago
3259
  <!-- ── Login screen ── -->
3260
  <div id="login-screen">
3261
    <div class="login-card">
3262
      <div class="brand">
3263
        <div class="icon">
Xdev Host Manager authored a week ago
3264
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3265
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3266
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3267
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3268
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3269
            <path d="M26 20h8M26 32h8M26 44h8"/>
3270
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3271
          </svg>
3272
        </div>
Xdev Host Manager authored a week ago
3273
        <h1>Madagascar Local Authority</h1>
3274
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3275
      </div>
Bogdan Timofte authored 4 days ago
3276
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3277
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3278
        <div class="pm-helper-fields" aria-hidden="true">
3279
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3280
          <input type="hidden" id="otp-hidden" name="otp">
3281
        </div>
Xdev Host Manager authored a week ago
3282
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3283
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3284
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3285
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3286
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3287
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3288
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3289
        </div>
3290
      </form>
3291
    </div>
3292
  </div>
3293

            
3294
  <!-- ── App (shown after login) ── -->
3295
  <div id="app">
3296
    <header>
Xdev Host Manager authored a week ago
3297
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3298
      <nav aria-label="Sections">
3299
        <a href="/overview" data-page-link="overview">Overview</a>
3300
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3301
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3302
        <a href="/dns" data-page-link="dns">DNS</a>
3303
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3304
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3305
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3306
      </nav>
Xdev Host Manager authored a week ago
3307
      <div class="header-right">
3308
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3309
        <span id="message" class="muted"></span>
3310
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3311
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3312
      </div>
Xdev Host Manager authored a week ago
3313
    </header>
3314
    <main>
Bogdan Timofte authored 5 days ago
3315
      <section class="page" id="page-overview" data-page="overview">
3316
        <section class="panel">
3317
          <div class="panel-head">
3318
            <h2>Overview</h2>
3319
            <div class="stats" id="stats"></div>
3320
          </div>
3321
          <div class="problems" id="problems"></div>
3322
        </section>
Xdev Host Manager authored a week ago
3323
      </section>
3324

            
Bogdan Timofte authored 5 days ago
3325
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3326
        <section class="panel">
3327
          <div class="panel-head">
3328
            <h2>Hosts</h2>
3329
            <div class="host-tools">
3330
              <input id="filter" placeholder="filter">
3331
              <button type="button" id="new-host">New host</button>
3332
            </div>
3333
          </div>
3334
          <div class="table-wrap">
3335
            <table>
3336
              <thead>
3337
                <tr>
Bogdan Timofte authored 4 days ago
3338
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 4 days ago
3339
                  <th>Aliases</th>
Bogdan Timofte authored 5 days ago
3340
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored 4 days ago
3341
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 5 days ago
3342
                  <th style="width: 110px">Monitoring</th>
3343
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3344
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3345
                </tr>
3346
              </thead>
3347
              <tbody id="hosts"></tbody>
3348
            </table>
3349
          </div>
3350
        </section>
Xdev Host Manager authored a week ago
3351
      </section>
Xdev Host Manager authored a week ago
3352

            
Bogdan Timofte authored 4 days ago
3353
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3354
        <section class="panel">
3355
          <div class="panel-head">
3356
            <h2>Vhosts</h2>
3357
            <div class="host-tools">
3358
              <input id="vhost-filter" placeholder="filter">
3359
              <div class="stats" id="vhost-stats"></div>
3360
            </div>
3361
          </div>
Bogdan Timofte authored 4 days ago
3362
          <div class="vhost-inline-editor">
3363
            <input id="vhost-new-name" placeholder="vhost fqdn">
3364
            <select id="vhost-new-host"></select>
3365
            <button type="button" id="vhost-add">Add</button>
3366
          </div>
Bogdan Timofte authored 4 days ago
3367
          <div class="table-wrap">
3368
            <table>
3369
              <thead>
3370
                <tr>
Bogdan Timofte authored 4 days ago
3371
                  <th style="width: 22%">Vhost</th>
3372
                  <th style="width: 24%">Host</th>
3373
                  <th style="width: 10%">IP</th>
3374
                  <th style="width: 30%">Certificate</th>
3375
                  <th style="width: 8%">Monitoring</th>
3376
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
3377
                </tr>
3378
              </thead>
3379
              <tbody id="vhosts"></tbody>
3380
            </table>
3381
          </div>
3382
        </section>
3383
      </section>
3384

            
Bogdan Timofte authored 5 days ago
3385
      <section class="page" id="page-dns" data-page="dns" hidden>
3386
        <section class="toolbar">
3387
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3388
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3389
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3390
          <button id="write-tsv">Write local-hosts.tsv</button>
3391
        </section>
Xdev Host Manager authored a week ago
3392
      </section>
3393

            
Bogdan Timofte authored 5 days ago
3394
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3395
        <section class="panel">
3396
          <div class="panel-head">
3397
            <h2>Work Orders</h2>
3398
            <div class="stats" id="wo-stats"></div>
3399
          </div>
3400
          <div class="problems" id="work-orders"></div>
3401
        </section>
Xdev Host Manager authored a week ago
3402
      </section>
3403

            
Bogdan Timofte authored 5 days ago
3404
      <section class="page" id="page-ca" data-page="ca" hidden>
3405
        <section class="panel">
3406
          <div class="panel-head">
3407
            <h2>Local Certificate Authority</h2>
3408
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3409
          </div>
3410
          <div class="problems" id="ca-status"></div>
3411
        </section>
3412
        <section class="panel">
3413
          <div class="panel-head">
3414
            <h2>Issued Certificates</h2>
3415
            <div class="stats" id="ca-certs-summary"></div>
3416
          </div>
3417
          <div class="table-wrap">
3418
            <table>
3419
              <thead>
3420
                <tr>
3421
                  <th style="width: 150px">Name</th>
3422
                  <th>DNS names</th>
3423
                  <th style="width: 210px">Validity</th>
3424
                  <th style="width: 180px">Serial</th>
3425
                  <th>Fingerprint</th>
3426
                  <th style="width: 110px">Download</th>
3427
                </tr>
3428
              </thead>
3429
              <tbody id="ca-certs"></tbody>
3430
            </table>
3431
          </div>
3432
        </section>
Xdev Host Manager authored a week ago
3433
      </section>
Bogdan Timofte authored 4 days ago
3434

            
3435
      <section class="page" id="page-debug" data-page="debug" hidden>
3436
        <section class="panel">
3437
          <div class="panel-head">
3438
            <h2>Database</h2>
3439
            <div class="stats" id="debug-db-stats"></div>
3440
          </div>
3441
          <div class="toolbar">
3442
            <div class="debug-controls">
3443
              <button type="button" id="debug-db-refresh">Refresh</button>
3444
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3445
            </div>
3446
          </div>
Bogdan Timofte authored 4 days ago
3447
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3448
        </section>
3449
        <section class="debug-section">
3450
          <section class="panel">
3451
            <div class="panel-head">
3452
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3453
              <div class="debug-table-head-actions">
3454
                <div class="stats" id="debug-table-stats"></div>
3455
                <div class="debug-table-exports">
3456
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3457
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3458
                </div>
3459
              </div>
Bogdan Timofte authored 4 days ago
3460
            </div>
3461
            <div class="table-wrap" id="debug-table-rows"></div>
3462
          </section>
3463
          <section class="panel">
3464
            <div class="panel-head">
3465
              <h2>Columns</h2>
3466
            </div>
3467
            <div class="table-wrap" id="debug-table-columns"></div>
3468
          </section>
3469
          <section class="panel">
3470
            <div class="panel-head">
3471
              <h2>Indexes</h2>
3472
            </div>
3473
            <div class="table-wrap" id="debug-table-indexes"></div>
3474
          </section>
3475
          <section class="panel">
3476
            <div class="panel-head">
3477
              <h2>Foreign Keys</h2>
3478
            </div>
3479
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3480
          </section>
3481
        </section>
3482
      </section>
Bogdan Timofte authored 5 days ago
3483
    </main>
Xdev Host Manager authored a week ago
3484

            
3485
  </div>
3486

            
Bogdan Timofte authored 4 days ago
3487
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3488
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3489
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3490
  </div>
Bogdan Timofte authored 6 days ago
3491

            
Xdev Host Manager authored a week ago
3492
  <script>
Bogdan Timofte authored 4 days ago
3493
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3494
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3495
    let hostFormBusy = false;
3496
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3497
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3498

            
3499
    const $ = (id) => document.getElementById(id);
3500
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 4 days ago
3501
    const hostFormShell = document.createElement('div');
3502
    hostFormShell.id = 'host-form-shell';
3503
    hostFormShell.className = 'host-inline-editor-shell';
3504
    hostFormShell.hidden = true;
3505
    hostFormShell.innerHTML = `
3506
      <div class="host-inline-editor-head">
3507
        <h2 id="host-form-title">New host</h2>
3508
        <div class="host-inline-editor-tools">
3509
          <button type="button" id="cancel-host-form">Close</button>
3510
        </div>
3511
      </div>
3512
      <form id="host-form" class="grid">
Bogdan Timofte authored 4 days ago
3513
        <label>Legacy ID<input name="id" required></label>
Bogdan Timofte authored 4 days ago
3514
        <label>FQDN<input name="fqdn" required></label>
3515
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3516
        <label>IP<input name="ip" required></label>
3517
        <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3518
        <label>Roles<input name="roles"></label>
3519
        <label>Sources<input name="sources"></label>
3520
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3521
        <label>Notes<input name="notes"></label>
3522
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3523
        <div class="span2 form-actions">
3524
          <button class="primary" type="submit" id="save-host">Save host</button>
3525
          <button class="danger" type="button" id="delete-host">Delete host</button>
3526
        </div>
3527
      </form>`;
3528
    const hostForm = hostFormShell.querySelector('#host-form');
3529
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3530
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3531
    const saveHostButton = hostFormShell.querySelector('#save-host');
3532
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3533
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
3534
    const hostEditorRow = document.createElement('tr');
3535
    hostEditorRow.className = 'host-inline-row';
3536
    const hostEditorCell = document.createElement('td');
3537
    hostEditorCell.colSpan = 7;
3538
    hostEditorRow.appendChild(hostEditorCell);
3539
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
3540
    const PAGE_PATHS = {
3541
      '/': 'overview',
3542
      '/overview': 'overview',
3543
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3544
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3545
      '/dns': 'dns',
3546
      '/work-orders': 'work-orders',
3547
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3548
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3549
    };
Xdev Host Manager authored a week ago
3550

            
Bogdan Timofte authored 4 days ago
3551
    function isAuthLost(error) {
3552
      return !!(error && error.authLost);
3553
    }
3554

            
3555
    function authLostError(message) {
3556
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3557
      error.authLost = true;
3558
      return error;
3559
    }
3560

            
3561
    function handleAuthLost(message) {
3562
      state.authenticated = false;
3563
      msg('');
3564
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3565
    }
3566

            
Bogdan Timofte authored 4 days ago
3567
    async function ensureAuthenticated(message) {
3568
      if (!state.authenticated) {
3569
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3570
        return false;
3571
      }
3572
      const session = await api('/api/session');
3573
      state.authenticated = session.authenticated;
3574
      if (!state.authenticated) {
3575
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3576
        return false;
3577
      }
3578
      return true;
3579
    }
3580

            
Xdev Host Manager authored a week ago
3581
    async function api(path, options = {}) {
3582
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3583
      let body = {};
3584
      try {
3585
        body = await res.json();
3586
      } catch (_) {
3587
        body = {};
3588
      }
3589
      const errorCode = body.error || '';
3590
      if (!res.ok) {
3591
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3592
          const error = authLostError();
3593
          handleAuthLost(error.message);
3594
          throw error;
3595
        }
3596
        throw new Error(errorCode || res.statusText);
3597
      }
Xdev Host Manager authored a week ago
3598
      return body;
3599
    }
3600

            
Bogdan Timofte authored 5 days ago
3601
    function currentPage() {
3602
      return PAGE_PATHS[window.location.pathname] || 'overview';
3603
    }
3604

            
3605
    function showPage(page, push = false) {
3606
      const target = page || 'overview';
3607
      document.querySelectorAll('[data-page]').forEach(section => {
3608
        section.hidden = section.dataset.page !== target;
3609
      });
3610
      document.querySelectorAll('[data-page-link]').forEach(link => {
3611
        link.classList.toggle('active', link.dataset.pageLink === target);
3612
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3613
      });
3614
      if (push) {
3615
        const href = target === 'overview' ? '/overview' : '/' + target;
3616
        history.pushState({ page: target }, '', href);
3617
      }
Bogdan Timofte authored 4 days ago
3618
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3619
        renderDebugDatabase().catch(e => {
3620
          if (!isAuthLost(e)) msg(e.message);
3621
        });
Bogdan Timofte authored 4 days ago
3622
      }
Bogdan Timofte authored 5 days ago
3623
    }
3624

            
Xdev Host Manager authored a week ago
3625
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3626
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3627
      document.body.classList.remove('is-app');
3628
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3629
      $('app').style.display = 'none';
3630
      $('login-screen').style.display = 'flex';
3631
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3632
      clearOtp();
Xdev Host Manager authored a week ago
3633
    }
3634

            
3635
    function showApp() {
Bogdan Timofte authored 6 days ago
3636
      document.body.classList.remove('is-login');
3637
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3638
      $('login-screen').style.display = 'none';
3639
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3640
      showPage(currentPage());
Xdev Host Manager authored a week ago
3641
    }
3642

            
Xdev Host Manager authored a week ago
3643
    async function refresh() {
3644
      const session = await api('/api/session');
3645
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3646
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3647
      showApp();
Xdev Host Manager authored a week ago
3648
      const data = await api('/api/hosts');
3649
      state.hosts = data.hosts || [];
Bogdan Timofte authored 4 days ago
3650
      state.vhosts = data.vhosts || [];
3651
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3652
      state.problems = data.problems || [];
3653
      render(data);
Xdev Host Manager authored a week ago
3654
      await renderCa();
Xdev Host Manager authored a week ago
3655
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3656
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3657
    }
3658

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

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

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

            
3672
      renderHosts();
Bogdan Timofte authored 4 days ago
3673
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3674
      renderVhosts();
Xdev Host Manager authored a week ago
3675
    }
3676

            
Xdev Host Manager authored a week ago
3677
    async function renderCa() {
3678
      try {
3679
        const status = await api('/api/ca/status');
3680
        if (!status.initialized) {
3681
          $('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
3682
          $('ca-certs-summary').innerHTML = '';
3683
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3684
          return;
3685
        }
3686
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 4 days ago
3687
        state.certificates = certs.map(cert => ({
3688
          ...cert,
3689
          id: cert.id || cert.name || '',
3690
          name: cert.name || cert.id || '',
3691
          has_private_key: !!cert.has_private_key
3692
        }));
Bogdan Timofte authored 5 days ago
3693
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3694
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3695
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3696
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3697
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3698
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3699
            <div>
3700
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3701
              <span>${certs.length} issued certificate(s)</span>
3702
            </div>
Xdev Host Manager authored a week ago
3703
          </div>`;
Bogdan Timofte authored 5 days ago
3704
        $('ca-certs-summary').innerHTML = [
3705
          ['issued', certs.length],
3706
          ['expiring', certs.filter(cert => {
3707
            const days = daysUntil(cert.not_after);
3708
            return days !== null && days >= 0 && days <= 30;
3709
          }).length],
3710
          ['expired', certs.filter(cert => {
3711
            const days = daysUntil(cert.not_after);
3712
            return days !== null && days < 0;
3713
          }).length],
3714
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3715
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3716
          const days = daysUntil(cert.not_after);
3717
          const dnsNames = cert.dns_names || [];
3718
          const dnsHtml = dnsNames.length
3719
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3720
            : '<span class="muted">No DNS SANs reported.</span>';
3721
          return `<tr>
3722
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3723
            <td>${dnsHtml}</td>
3724
            <td>
3725
              <div class="ca-detail">
3726
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3727
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3728
              </div>
3729
            </td>
3730
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3731
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 4 days ago
3732
            <td>
3733
              <div class="vhost-cert-links">
3734
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
3735
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
3736
              </div>
3737
            </td>
Bogdan Timofte authored 5 days ago
3738
          </tr>`;
3739
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3740
      } catch (e) {
Bogdan Timofte authored 4 days ago
3741
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3742
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3743
        $('ca-certs-summary').innerHTML = '';
3744
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3745
      }
3746
    }
3747

            
Bogdan Timofte authored 5 days ago
3748
    function daysUntil(dateText) {
3749
      const time = Date.parse(dateText || '');
3750
      if (!Number.isFinite(time)) return null;
3751
      return Math.ceil((time - Date.now()) / 86400000);
3752
    }
3753

            
3754
    function certStatusClass(days) {
3755
      if (days === null) return '';
3756
      if (days < 0) return 'bad';
3757
      if (days <= 30) return 'warn';
3758
      return 'ok';
3759
    }
3760

            
3761
    function certStatusLabel(days) {
3762
      if (days === null) return 'validity unknown';
3763
      if (days < 0) return 'expired';
3764
      if (days === 0) return 'expires today';
3765
      return `${days}d remaining`;
3766
    }
3767

            
Xdev Host Manager authored a week ago
3768
    async function renderWorkOrders() {
3769
      try {
3770
        const data = await api('/api/work-orders');
3771
        state.workOrders = data.work_orders || [];
3772
        $('wo-stats').innerHTML = [
3773
          ['pending', data.counts.pending],
3774
          ['total', data.counts.work_orders],
3775
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3776

            
3777
        if (!state.workOrders.length) {
3778
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3779
          return;
3780
        }
3781

            
3782
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3783
          const checklist = wo.checklist || [];
3784
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3785
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3786
          const checklistHtml = checklist.map(item => {
3787
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3788
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3789
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3790
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3791
            </label>`;
3792
          }).join('');
Xdev Host Manager authored a week ago
3793
          const actions = (wo.actions || []).map(a => {
3794
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3795
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3796
          }).join('');
3797
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3798
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3799
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3800
            : '';
Bogdan Timofte authored 6 days ago
3801
          return `<div class="problem work-order-card">
3802
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3803
              <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
3804
              ${button}
3805
            </div>
Bogdan Timofte authored 6 days ago
3806
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3807
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3808
            <div class="work-order-checklist">${checklistHtml}</div>
3809
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3810
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3811
          </div>`;
3812
        }).join('');
Xdev Host Manager authored a week ago
3813
        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
3814
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3815
      } catch (e) {
Bogdan Timofte authored 4 days ago
3816
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3817
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3818
      }
3819
    }
3820

            
Bogdan Timofte authored 4 days ago
3821
    async function renderDebugDatabase() {
3822
      if (!state.authenticated) return;
3823
      const data = await api('/api/debug/database/tables');
3824
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3825
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3826
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3827
      $('debug-db-stats').innerHTML = [
3828
        ['tables', data.counts ? data.counts.tables : tables.length],
3829
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3830
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3831
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3832
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3833
      if (selected) {
3834
        await renderDebugTable(selected);
3835
      } else {
3836
        clearDebugTable();
3837
      }
3838
    }
3839

            
Bogdan Timofte authored 4 days ago
3840
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3841
      $('debug-db-tables').innerHTML = tables.length
3842
        ? tables.map(table => {
3843
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3844
            const ref = debugTableReference(database, table.name);
3845
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3846
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3847
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3848
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3849
              </button>
Bogdan Timofte authored 4 days ago
3850
              <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
3851
            </div>`;
Bogdan Timofte authored 4 days ago
3852
          }).join('')
3853
        : '<div class="ca-empty muted">No database tables found.</div>';
3854
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3855
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3856
          if (!isAuthLost(e)) msg(e.message);
3857
        }));
3858
      });
Bogdan Timofte authored 4 days ago
3859
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3860
        button.addEventListener('click', async () => {
3861
          try {
3862
            await copyText(button.dataset.debugTableRef || '');
3863
            msg('table reference copied');
3864
          } catch (e) {
3865
            msg('copy failed');
3866
          }
3867
        });
3868
      });
3869
    }
3870

            
3871
    function debugTableReference(database, tableName) {
3872
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3873
    }
3874

            
3875
    async function selectDebugTable(tableName) {
3876
      state.debugTable = tableName || '';
3877
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3878
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3879
        const card = button.closest('.debug-table-card');
3880
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3881
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3882
      });
3883
      if (state.debugTable) await renderDebugTable(state.debugTable);
3884
    }
3885

            
3886
    function clearDebugTable() {
3887
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3888
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3889
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3890
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3891
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3892
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3893
    }
3894

            
3895
    async function renderDebugTable(tableName) {
3896
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3897
      if (data.error) throw new Error(data.error);
3898
      $('debug-table-stats').innerHTML = [
3899
        ['table', data.table || tableName],
3900
        ['rows', data.row_count || 0],
3901
        ['shown', (data.rows || []).length],
3902
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3903
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3904
      renderDebugRows(data);
3905
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3906
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3907
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3908
    }
3909

            
Bogdan Timofte authored 4 days ago
3910
    function updateDebugExportLinks(tableName) {
3911
      const encoded = encodeURIComponent(tableName || '');
3912
      [
3913
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3914
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3915
      ].forEach(([id, href]) => {
3916
        const link = $(id);
3917
        const enabled = !!tableName;
3918
        link.href = enabled ? href : '#';
3919
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3920
      });
3921
    }
3922

            
Bogdan Timofte authored 4 days ago
3923
    function renderDebugRows(data) {
3924
      const rows = data.rows || [];
3925
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3926
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3927
    }
3928

            
3929
    function renderDebugObjectTable(rows, preferredKeys) {
3930
      const keys = preferredKeys && preferredKeys.length
3931
        ? preferredKeys
3932
        : Array.from(rows.reduce((set, row) => {
3933
            Object.keys(row || {}).forEach(key => set.add(key));
3934
            return set;
3935
          }, new Set()));
3936
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3937
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3938
      const body = rows.length
3939
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3940
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3941
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3942
    }
3943

            
3944
    function debugCell(value) {
3945
      if (value === null || value === undefined) return 'NULL';
3946
      if (Array.isArray(value)) return value.join(', ');
3947
      if (typeof value === 'object') return JSON.stringify(value);
3948
      return String(value);
3949
    }
3950

            
Xdev Host Manager authored a week ago
3951
    async function updateWorkOrderChecklist(id, itemId, checked) {
3952
      try {
3953
        await api('/api/work-orders/checklist', {
3954
          method: 'POST',
3955
          headers: { 'Content-Type': 'application/json' },
3956
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3957
        });
3958
        msg('work order updated');
3959
        await refresh();
Bogdan Timofte authored 4 days ago
3960
      } catch (e) {
3961
        if (isAuthLost(e)) return;
3962
        msg(e.message);
3963
        await refresh().catch(refreshError => {
3964
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3965
        });
3966
      }
Xdev Host Manager authored a week ago
3967
    }
3968

            
Xdev Host Manager authored a week ago
3969
    async function confirmWorkOrder(id) {
3970
      const typed = prompt(`Type ${id} to confirm this work order`);
3971
      if (typed !== id) return;
3972
      try {
3973
        await api('/api/work-orders/confirm', {
3974
          method: 'POST',
3975
          headers: { 'Content-Type': 'application/json' },
3976
          body: JSON.stringify({ id, confirm: typed })
3977
        });
3978
        msg('work order confirmed; local-hosts.tsv written');
3979
        await refresh();
Bogdan Timofte authored 4 days ago
3980
      } catch (e) {
3981
        if (isAuthLost(e)) return;
3982
        msg(e.message);
3983
      }
Xdev Host Manager authored a week ago
3984
    }
3985

            
Xdev Host Manager authored a week ago
3986
    function renderHosts() {
3987
      const filter = $('filter').value.toLowerCase();
3988
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3989
        .slice()
Bogdan Timofte authored 4 days ago
3990
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
3991
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3992
        .map(h => {
3993
          const problems = state.problems.filter(p => p.host_id === h.id);
3994
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 4 days ago
3995
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 4 days ago
3996
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
3997
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
3998
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored 4 days ago
3999
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
4000
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4001
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 4 days ago
4002
            <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
Xdev Host Manager authored a week ago
4003
          </tr>`;
4004
        }).join('');
Bogdan Timofte authored 4 days ago
4005
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
4006
        editHost(button.dataset.edit).catch(e => {
4007
          if (!isAuthLost(e)) msg(e.message);
4008
        });
4009
      }));
Bogdan Timofte authored 4 days ago
4010
      document.querySelectorAll('[data-host-alias-add]').forEach(button => button.addEventListener('click', () => {
4011
        addHostAlias(button.dataset.hostAliasAdd || '').catch(e => {
4012
          if (!isAuthLost(e)) msg(e.message);
4013
        });
4014
      }));
4015
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4016
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4017
          if (!isAuthLost(e)) msg(e.message);
4018
        });
4019
      }));
4020
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4021
        select.addEventListener('change', () => {
4022
          setHostCertificateFromSelect(select).catch(e => {
4023
            if (!isAuthLost(e)) msg(e.message);
4024
            select.value = select.dataset.currentCertificate || '';
4025
          });
4026
        });
4027
      });
4028
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4029
        button.addEventListener('click', () => {
4030
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4031
            if (!isAuthLost(e)) msg(e.message);
4032
          });
4033
        });
4034
      });
Bogdan Timofte authored 4 days ago
4035
      mountHostEditor();
Xdev Host Manager authored a week ago
4036
    }
4037

            
Bogdan Timofte authored 4 days ago
4038
    function renderHostAliasCell(host) {
4039
      const canonical = host.fqdn ? `<span class="pill canonical host-alias-pill"><span class="host-alias-label">${escapeHtml(host.fqdn)}</span></span>` : '';
4040
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4041
        <span class="host-alias-label">${escapeHtml(name)}</span>
4042
        <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>
4043
      </span>`).join('');
4044
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived host-alias-pill" title="derived alias">
4045
        <span class="host-alias-label">${escapeHtml(name)}</span>
4046
      </span>`).join('');
4047
      return `<div class="host-alias-cell">
4048
        <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>
4049
      </div>`;
4050
    }
4051

            
4052
    function renderHostCertificateCell(host) {
4053
      const cert = host.certificate || {};
4054
      const certId = host.certificate_id || certId(cert) || '';
4055
      const row = hostCertificateRow(host);
4056
      const links = certId ? `<div class="vhost-cert-links">
4057
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4058
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4059
      </div>` : '';
4060
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4061
      return `<div class="vhost-cert">
4062
        <div class="vhost-cert-main">
4063
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4064
            ${renderCertificateOptions(certId, row)}
4065
          </select>
4066
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4067
        </div>
4068
        <div class="vhost-cert-meta">${links}${validity}</div>
4069
      </div>`;
4070
    }
4071

            
4072
    function hostCertificateRow(host) {
4073
      return {
4074
        host_fqdn: host.fqdn || '',
4075
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4076
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4077
        certificate_id: host.certificate_id || '',
4078
        certificate: host.certificate || null,
4079
      };
Bogdan Timofte authored 4 days ago
4080
    }
4081

            
4082
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4083
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4084
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4085
        vhost,
4086
        host_id: host.id || '',
4087
        host_fqdn: host.fqdn || '',
4088
        ip: host.ip || '',
4089
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4090
        monitoring: host.monitoring || '',
4091
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4092
        certificate_id: '',
4093
        certificate: null,
Bogdan Timofte authored 4 days ago
4094
      })));
4095
    }
4096

            
4097
    function renderVhosts() {
4098
      const input = $('vhost-filter');
4099
      const filter = input ? input.value.toLowerCase() : '';
4100
      const rows = vhostRows()
4101
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
4102
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
4103
      $('vhost-stats').innerHTML = [
4104
        ['shown', rows.length],
4105
        ['total', vhostRows().length],
4106
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4107
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
Bogdan Timofte authored 4 days ago
4108
        <td>${renderVhostNameCell(row)}</td>
Bogdan Timofte authored 4 days ago
4109
        <td>
4110
          <div class="vhost-host">
4111
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
4112
              ${renderVhostHostOptions(row.host_fqdn)}
4113
            </select>
4114
          </div>
4115
        </td>
Bogdan Timofte authored 4 days ago
4116
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
4117
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
4118
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
4119
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
4120
      </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
4121
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
4122
        select.addEventListener('change', () => {
4123
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
4124
            if (!isAuthLost(e)) msg(e.message);
4125
            select.value = select.dataset.currentHost || '';
4126
          });
Bogdan Timofte authored 4 days ago
4127
        });
Bogdan Timofte authored 4 days ago
4128
      });
Bogdan Timofte authored 4 days ago
4129
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
4130
        button.addEventListener('click', () => {
4131
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
4132
            if (!isAuthLost(e)) msg(e.message);
4133
          });
4134
        });
4135
      });
Bogdan Timofte authored 4 days ago
4136
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
4137
        select.addEventListener('change', () => {
4138
          setVhostCertificateFromSelect(select).catch(e => {
4139
            if (!isAuthLost(e)) msg(e.message);
4140
            select.value = select.dataset.currentCertificate || '';
4141
          });
4142
        });
4143
      });
4144
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
4145
        button.addEventListener('click', () => {
Bogdan Timofte authored 4 days ago
4146
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 4 days ago
4147
            if (!isAuthLost(e)) msg(e.message);
4148
          });
4149
        });
4150
      });
4151
    }
4152

            
Bogdan Timofte authored 4 days ago
4153
    function renderVhostNameCell(row) {
4154
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4155
      return `<div class="vhost-name-cell">
4156
        <div class="vhost-name-main">
4157
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4158
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4159
        </div>
4160
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4161
      </div>`;
4162
    }
4163

            
Bogdan Timofte authored 4 days ago
4164
    function renderVhostCertificateCell(row) {
4165
      const cert = row.certificate || {};
4166
      const certId = row.certificate_id || cert.id || cert.name || '';
4167
      const links = certId ? `<div class="vhost-cert-links">
4168
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4169
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4170
      </div>` : '';
4171
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4172
      return `<div class="vhost-cert">
4173
        <div class="vhost-cert-main">
4174
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 4 days ago
4175
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 4 days ago
4176
          </select>
4177
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4178
        </div>
4179
        <div class="vhost-cert-meta">${links}${validity}</div>
4180
      </div>`;
Bogdan Timofte authored 4 days ago
4181
    }
4182

            
4183
    function renderVhostEditor() {
4184
      const select = $('vhost-new-host');
4185
      const current = select.value || '';
4186
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4187
    }
4188

            
4189
    function renderVhostHostOptions(selectedHostFqdn) {
4190
      return state.hosts
4191
        .slice()
4192
        .filter(host => (host.status || '') !== 'retired')
4193
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4194
        .map(host => {
4195
          const fqdn = host.fqdn || '';
4196
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4197
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4198
        }).join('');
Bogdan Timofte authored 4 days ago
4199
    }
4200

            
Bogdan Timofte authored 4 days ago
4201
    function renderCertificateOptions(selectedCertificateId, row) {
4202
      const byId = new Map();
4203
      (state.certificates || []).forEach(cert => {
4204
        const id = certId(cert);
4205
        if (id) byId.set(id, cert);
4206
      });
4207
      if (row && row.certificate) {
4208
        const id = certId(row.certificate);
4209
        if (id && !byId.has(id)) byId.set(id, row.certificate);
4210
      }
4211
      const certs = Array.from(byId.values())
4212
        .filter(cert => certMatchesRow(cert, row) || certId(cert) === selectedCertificateId)
4213
        .sort((a, b) => {
4214
          const ar = certRelevance(a, row);
4215
          const br = certRelevance(b, row);
4216
          if (ar !== br) return ar - br;
4217
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4218
        });
Bogdan Timofte authored 4 days ago
4219
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 4 days ago
4220
        const id = certId(cert);
4221
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 4 days ago
4222
        const selected = id === selectedCertificateId ? ' selected' : '';
4223
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
4224
      }));
4225
      return options.join('');
4226
    }
4227

            
Bogdan Timofte authored 4 days ago
4228
    function certId(cert) {
4229
      return cert ? (cert.id || cert.name || '') : '';
4230
    }
4231

            
4232
    function certDnsNames(cert) {
4233
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4234
        .map(name => String(name || '').toLowerCase())
4235
        .filter(Boolean);
4236
    }
4237

            
4238
    function certRelevance(cert, row) {
4239
      if (!row) return 9;
4240
      const names = new Set(certDnsNames(cert));
4241
      const id = String(certId(cert)).toLowerCase();
4242
      const commonName = String(cert.common_name || '').toLowerCase();
4243
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4244
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4245
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 4 days ago
4246
      const aliasNames = []
4247
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4248
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4249
        .map(name => String(name || '').toLowerCase())
4250
        .filter(Boolean);
4251
      if (vhost) {
4252
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4253
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4254
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4255
        return 9;
4256
      }
4257
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4258
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 4 days ago
4259
      return 9;
4260
    }
4261

            
4262
    function certMatchesRow(cert, row) {
4263
      return certRelevance(cert, row) < 9;
4264
    }
4265

            
4266
    function compactCertificateLabel(cert, row) {
4267
      const relevance = certRelevance(cert, row);
4268
      const id = String(certId(cert));
4269
      const days = daysUntil(cert.not_after);
4270
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
4271
      const timestamp = id.match(/-(\d{14})$/);
Bogdan Timofte authored 4 days ago
4272
      if (row && row.vhost) {
4273
        if (relevance === 0) return `vhost${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4274
        if (relevance === 1) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4275
        if (relevance === 2) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4276
      } else {
4277
        if (relevance === 0) return `host${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4278
        if (relevance === 1) return `alias${timestamp ? ' ' + timestamp[1] : ''}${suffix}`;
4279
      }
Bogdan Timofte authored 4 days ago
4280
      return `${shortCertificateName(cert)}${suffix}`;
4281
    }
4282

            
4283
    function shortCertificateName(cert) {
4284
      const name = String(cert.common_name || cert.name || cert.id || '');
4285
      const suffix = '.madagascar.xdev.ro';
4286
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4287
    }
4288

            
Bogdan Timofte authored 4 days ago
4289
    function shortAliasForFqdn(name) {
4290
      const suffix = '.madagascar.xdev.ro';
4291
      name = String(name || '').toLowerCase();
4292
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
4293
    }
4294

            
Bogdan Timofte authored 4 days ago
4295
    function hostByFqdn(fqdn) {
4296
      fqdn = String(fqdn || '').toLowerCase();
4297
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4298
    }
4299

            
4300
    function hostUpsertPayload(host, overrides = {}) {
4301
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4302
      const payload = {
4303
        id: host.id || '',
4304
        fqdn: host.fqdn || '',
4305
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4306
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4307
        aliases,
4308
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
4309
        sources: Array.isArray(overrides.sources) ? overrides.sources : (host.sources || []),
4310
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4311
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4312
      };
4313
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4314
      return payload;
4315
    }
4316

            
4317
    async function addHostAlias(hostFqdn) {
4318
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4319
      const host = hostByFqdn(hostFqdn);
4320
      if (!host) return;
4321
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4322
      if (!alias) return;
4323
      if (alias === String(host.fqdn || '').toLowerCase()) {
4324
        msg('fqdn-ul hostului este deja prezent');
4325
        return;
4326
      }
4327
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4328
      await api('/api/hosts/upsert', {
4329
        method: 'POST',
4330
        headers: { 'Content-Type': 'application/json' },
4331
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4332
      });
4333
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4334
      await refresh();
4335
    }
4336

            
4337
    async function removeHostAlias(hostFqdn, alias) {
4338
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4339
      const host = hostByFqdn(hostFqdn);
4340
      alias = String(alias || '').trim().toLowerCase();
4341
      if (!host || !alias) return;
4342
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4343
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4344
      await api('/api/hosts/upsert', {
4345
        method: 'POST',
4346
        headers: { 'Content-Type': 'application/json' },
4347
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4348
      });
4349
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4350
      await refresh();
4351
    }
4352

            
4353
    async function setHostCertificateFromSelect(select) {
4354
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4355
        select.value = select.dataset.currentCertificate || '';
4356
        return;
4357
      }
4358
      const hostFqdn = select.dataset.hostCertSelect || '';
4359
      const certificateId = select.value || '';
4360
      const current = select.dataset.currentCertificate || '';
4361
      if (!hostFqdn || certificateId === current) return;
4362
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4363
        select.value = current;
4364
        return;
4365
      }
4366
      select.disabled = true;
4367
      try {
4368
        await api('/api/hosts/certificate', {
4369
          method: 'POST',
4370
          headers: { 'Content-Type': 'application/json' },
4371
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4372
        });
4373
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4374
        await refresh();
4375
      } finally {
4376
        select.disabled = false;
4377
      }
4378
    }
4379

            
4380
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4381
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4382
      if (!hostFqdn) return;
4383
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4384
      if (button) button.disabled = true;
4385
      try {
4386
        const result = await api('/api/hosts/issue-certificate', {
4387
          method: 'POST',
4388
          headers: { 'Content-Type': 'application/json' },
4389
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4390
        });
4391
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4392
        await refresh();
4393
      } finally {
4394
        if (button) button.disabled = false;
4395
      }
4396
    }
4397

            
Bogdan Timofte authored 4 days ago
4398
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4399
      const vhost = select.dataset.vhostSelect || '';
4400
      const fromHost = select.dataset.currentHost || '';
4401
      const toHost = select.value || '';
4402
      if (!vhost || !toHost || toHost === fromHost) return;
4403
      select.disabled = true;
4404
      try {
4405
        await api('/api/vhosts/reassign', {
4406
          method: 'POST',
4407
          headers: { 'Content-Type': 'application/json' },
4408
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4409
        });
4410
        msg(`vhost ${vhost} moved`);
4411
        await refresh();
4412
      } finally {
4413
        select.disabled = false;
4414
      }
4415
    }
4416

            
Bogdan Timofte authored 4 days ago
4417
    async function addVhostInline() {
4418
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4419
      const nameInput = $('vhost-new-name');
4420
      const hostSelect = $('vhost-new-host');
4421
      const vhost = (nameInput.value || '').trim().toLowerCase();
4422
      const hostFqdn = hostSelect.value || '';
4423
      if (!vhost || !hostFqdn) return;
4424
      $('vhost-add').disabled = true;
4425
      nameInput.disabled = true;
4426
      hostSelect.disabled = true;
4427
      try {
4428
        await api('/api/vhosts/upsert', {
4429
          method: 'POST',
4430
          headers: { 'Content-Type': 'application/json' },
4431
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4432
        });
4433
        nameInput.value = '';
4434
        msg(`vhost ${vhost} saved`);
4435
        await refresh();
4436
      } finally {
4437
        $('vhost-add').disabled = false;
4438
        nameInput.disabled = false;
4439
        hostSelect.disabled = false;
4440
      }
4441
    }
4442

            
Bogdan Timofte authored 4 days ago
4443
    async function setVhostCertificateFromSelect(select) {
4444
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4445
        select.value = select.dataset.currentCertificate || '';
4446
        return;
4447
      }
4448
      const vhost = select.dataset.vhostCertSelect || '';
4449
      const certificateId = select.value || '';
4450
      const current = select.dataset.currentCertificate || '';
4451
      if (!vhost || certificateId === current) return;
4452
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4453
        select.value = current;
4454
        return;
4455
      }
4456
      select.disabled = true;
4457
      try {
4458
        await api('/api/vhosts/certificate', {
4459
          method: 'POST',
4460
          headers: { 'Content-Type': 'application/json' },
4461
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4462
        });
4463
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4464
        await refresh();
4465
      } finally {
4466
        select.disabled = false;
4467
      }
4468
    }
4469

            
Bogdan Timofte authored 4 days ago
4470
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4471
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4472
      if (!vhost) return;
4473
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4474
      if (button) button.disabled = true;
4475
      try {
4476
        const result = await api('/api/vhosts/issue-certificate', {
4477
          method: 'POST',
4478
          headers: { 'Content-Type': 'application/json' },
4479
          body: JSON.stringify({ vhost_fqdn: vhost }),
4480
        });
4481
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4482
        await refresh();
4483
      } finally {
4484
        if (button) button.disabled = false;
4485
      }
4486
    }
4487

            
Bogdan Timofte authored 4 days ago
4488
    async function deleteVhostInline(vhost) {
4489
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4490
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4491
      await api('/api/vhosts/delete', {
4492
        method: 'POST',
4493
        headers: { 'Content-Type': 'application/json' },
4494
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4495
      });
4496
      msg(`vhost ${vhost} deleted`);
4497
      await refresh();
4498
    }
4499

            
Bogdan Timofte authored 4 days ago
4500
    async function editHost(id) {
4501
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4502
      const host = state.hosts.find(h => h.id === id);
4503
      if (!host) return;
Bogdan Timofte authored 4 days ago
4504
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4505
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4506
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4507
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4508
      hostField('roles').value = (host.roles || []).join(' ');
4509
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4510
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4511
    }
4512

            
Bogdan Timofte authored 4 days ago
4513
    async function newHost() {
4514
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4515
      if (!canSwitchHostEditor('__new__')) return;
4516
      resetHostForm(true);
4517
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4518
    }
4519

            
Bogdan Timofte authored 4 days ago
4520
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4521
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4522
      hostEditorTarget = target || '';
4523
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4524
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4525
      renderHosts();
4526
      hostFormSnapshot = hostFormState();
4527
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4528
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4529
    }
4530

            
Bogdan Timofte authored 4 days ago
4531
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4532
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4533
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4534
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4535
      hostField('status').value = 'active';
4536
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4537
      hostFormSnapshot = force ? '' : hostFormState();
4538
    }
4539

            
4540
    function closeHostForm(force = false) {
4541
      if (hostFormBusy && !force) return;
4542
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4543
      hostEditorTarget = '';
4544
      hostFormMode = 'new';
4545
      hostFormSnapshot = '';
4546
      clearHostFormMessage();
4547
      syncHostFormActions();
4548
      mountHostEditor();
4549
    }
4550

            
4551
    function canSwitchHostEditor(target) {
4552
      if (hostFormBusy) return false;
4553
      if (!hostEditorTarget) return true;
4554
      if (!hostFormDirty()) return true;
4555
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4556
      return confirm('Discard unsaved host changes?');
4557
    }
4558

            
4559
    function mountHostEditor() {
4560
      hostEditorRow.remove();
4561
      if (!hostEditorTarget) {
4562
        hostFormShell.hidden = true;
4563
        return;
4564
      }
4565
      hostEditorCell.colSpan = 7;
4566
      const tbody = $('hosts');
4567
      if (!tbody) return;
4568
      if (hostEditorTarget === '__new__') {
4569
        tbody.prepend(hostEditorRow);
4570
      } else {
4571
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4572
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4573
        if (targetRow) targetRow.after(hostEditorRow);
4574
        else tbody.prepend(hostEditorRow);
4575
      }
4576
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4577
    }
4578

            
4579
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4580
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4581
    }
4582

            
4583
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4584
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4585
    }
4586

            
4587
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4588
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4589
    }
4590

            
4591
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4592
      hostFormBusy = !!busy;
4593
      syncHostFormActions();
4594
    }
4595

            
4596
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4597
      saveHostButton.disabled = hostFormBusy;
4598
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4599
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4600
    }
4601

            
4602
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4603
      hostFormMessage.textContent = text || '';
4604
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4605
    }
4606

            
4607
    function clearHostFormMessage() {
4608
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4609
    }
4610

            
4611
    function formObject(form) {
4612
      return Object.fromEntries(new FormData(form).entries());
4613
    }
4614

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

            
Bogdan Timofte authored 6 days ago
4620
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4621

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

            
4627
    if (loginAccount) {
4628
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4629
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4630
      loginAccount.addEventListener('input', () => {
4631
        const value = (loginAccount.value || '').trim();
4632
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4633
      });
4634
    }
4635

            
Xdev Host Manager authored a week ago
4636
    function setOtpDigit(idx, value) {
4637
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4638
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4639
      otpDigits[idx].classList.toggle('filled', !!digit);
4640
    }
4641

            
Bogdan Timofte authored 4 days ago
4642
    // Move focus to the next empty box: forward from idx, then wrapping to the
4643
    // start. This lets out-of-order entry continue (e.g. after the last box,
4644
    // jump back to the first still-empty box). Stays put when all boxes are full.
4645
    function advanceFocus(idx) {
4646
      for (let i = idx + 1; i < otpDigits.length; i++) {
4647
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4648
      }
4649
      for (let i = 0; i <= idx; i++) {
4650
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4651
      }
4652
    }
4653

            
Bogdan Timofte authored 4 days ago
4654
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4655
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4656
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4657
      const digits = (text || '').replace(/\D/g, '').split('');
4658
      if (!digits.length) return;
4659
      let last = startIdx;
4660
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4661
        last = startIdx + i;
4662
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4663
      }
Bogdan Timofte authored 4 days ago
4664
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4665
      advanceFocus(last);
Xdev Host Manager authored a week ago
4666
      maybeSubmitOtp();
4667
    }
4668

            
Bogdan Timofte authored 4 days ago
4669
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4670
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4671
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4672
    function maybeSubmitOtp() {
4673
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4674
    }
4675
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4676
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4677
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4678
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4679
      // an unknown operator, so Safari's autofill anchor on the username stays.
4680
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4681
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4682
    }
4683

            
Bogdan Timofte authored 4 days ago
4684
    otpDigits.forEach((input, idx) => {
4685
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4686
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4687
        // A single box may receive several digits at once (autofill / typing fast).
4688
        if (input.value.replace(/\D/g, '').length > 1) {
4689
          fillOtp(input.value, idx);
4690
          return;
4691
        }
4692
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4693
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4694
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4695
        maybeSubmitOtp();
4696
      });
Bogdan Timofte authored 4 days ago
4697

            
4698
      input.addEventListener('paste', (e) => {
4699
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4700
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4701
        const text = (e.clipboardData || window.clipboardData).getData('text');
4702
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4703
      });
Bogdan Timofte authored 4 days ago
4704

            
4705
      input.addEventListener('keydown', (e) => {
4706
        if (e.key === 'Backspace') {
4707
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4708
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4709
          if (input.value) { setOtpDigit(idx, ''); }
4710
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4711
          syncOtpFields();
4712
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4713
          e.preventDefault();
4714
          otpDigits[idx - 1].focus();
4715
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4716
          e.preventDefault();
4717
          otpDigits[idx + 1].focus();
4718
        }
4719
      });
4720
    });
4721

            
Bogdan Timofte authored 4 days ago
4722
    // Focus the first OTP box only for a returning operator (username known).
4723
    // For an unknown operator, leave focus on the username field so Safari can
4724
    // present its OTP autofill anchored there without being dismissed by a focus
4725
    // change (pbx-admin pattern).
4726
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4727
    else if (loginAccount) loginAccount.focus();
4728
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4729

            
Bogdan Timofte authored 5 days ago
4730
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4731
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4732
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4733
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4734
        showPage(link.dataset.pageLink, true);
4735
      });
4736
    });
4737

            
Bogdan Timofte authored 4 days ago
4738
    window.addEventListener('popstate', () => {
4739
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4740
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4741
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4742
    });
Bogdan Timofte authored 5 days ago
4743

            
Bogdan Timofte authored 4 days ago
4744
    async function copyText(text) {
4745
      if (navigator.clipboard && window.isSecureContext) {
4746
        await navigator.clipboard.writeText(text);
4747
        return;
4748
      }
4749
      const input = document.createElement('textarea');
4750
      input.value = text;
4751
      input.setAttribute('readonly', '');
4752
      input.style.position = 'fixed';
4753
      input.style.left = '-10000px';
4754
      document.body.appendChild(input);
4755
      input.select();
4756
      document.execCommand('copy');
4757
      document.body.removeChild(input);
4758
    }
4759

            
4760
    $('copy-build').addEventListener('click', async () => {
4761
      try {
4762
        await copyText($('copy-build').dataset.buildDetails || '');
4763
        if (state.authenticated) msg('build details copied');
4764
      } catch (e) {
4765
        if (state.authenticated) msg('copy failed');
4766
      }
4767
    });
4768

            
Xdev Host Manager authored a week ago
4769
    $('login-form').addEventListener('submit', async (event) => {
4770
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4771
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4772
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4773
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4774
      try {
Xdev Host Manager authored a week ago
4775
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4776
        await refresh();
Xdev Host Manager authored a week ago
4777
      } catch (e) {
4778
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4779
      } finally {
Xdev Host Manager authored a week ago
4780
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4781
      }
Xdev Host Manager authored a week ago
4782
    });
4783

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

            
Bogdan Timofte authored 4 days ago
4789
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4790
      if (!isAuthLost(e)) msg(e.message);
4791
    }));
Xdev Host Manager authored a week ago
4792
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4793
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4794
    $('vhost-add').addEventListener('click', () => {
4795
      addVhostInline().catch(e => {
4796
        if (!isAuthLost(e)) msg(e.message);
4797
      });
4798
    });
4799
    $('vhost-new-name').addEventListener('keydown', (event) => {
4800
      if (event.key !== 'Enter') return;
4801
      event.preventDefault();
4802
      addVhostInline().catch(e => {
4803
        if (!isAuthLost(e)) msg(e.message);
4804
      });
4805
    });
Bogdan Timofte authored 4 days ago
4806
    $('new-host').addEventListener('click', () => {
4807
      newHost().catch(e => {
4808
        if (!isAuthLost(e)) msg(e.message);
4809
      });
4810
    });
Bogdan Timofte authored 4 days ago
4811
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4812
      if (!isAuthLost(e)) msg(e.message);
4813
    }));
Bogdan Timofte authored 4 days ago
4814
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4815

            
Bogdan Timofte authored 4 days ago
4816
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4817
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4818
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4819
      setHostFormBusy(true);
4820
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4821
      try {
Bogdan Timofte authored 4 days ago
4822
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4823
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4824
        msg('host saved');
4825
        await refresh();
Bogdan Timofte authored 4 days ago
4826
        const host = state.hosts.find(entry => entry.id === savedId);
4827
        if (host) {
4828
          clearHostFormMessage();
4829
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4830
          hostField('aliases').value = (host.aliases || []).join('\n');
4831
          hostField('roles').value = (host.roles || []).join(' ');
4832
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4833
          activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 4 days ago
4834
        } else {
Bogdan Timofte authored 4 days ago
4835
          closeHostForm(true);
Bogdan Timofte authored 4 days ago
4836
        }
Bogdan Timofte authored 5 days ago
4837
      } catch (e) {
Bogdan Timofte authored 4 days ago
4838
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4839
        setHostFormMessage(e.message, true);
4840
        msg(e.message);
4841
      } finally {
4842
        setHostFormBusy(false);
4843
      }
4844
    });
4845

            
Bogdan Timofte authored 4 days ago
4846
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4847
      setHostFormMessage('Complete the required host fields before saving.', true);
4848
    }, true);
4849

            
Bogdan Timofte authored 4 days ago
4850
    hostForm.addEventListener('input', () => {
4851
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4852
    });
4853

            
Bogdan Timofte authored 4 days ago
4854
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4855
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4856
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4857
      setHostFormBusy(true);
4858
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4859
      try {
4860
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4861
        msg('host deleted');
4862
        await refresh();
Bogdan Timofte authored 4 days ago
4863
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4864
      } catch (e) {
Bogdan Timofte authored 4 days ago
4865
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4866
        setHostFormMessage(e.message, true);
4867
        msg(e.message);
4868
      } finally {
4869
        setHostFormBusy(false);
4870
      }
Xdev Host Manager authored a week ago
4871
    });
4872

            
Bogdan Timofte authored 4 days ago
4873
    resetHostForm(true);
4874
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4875

            
Xdev Host Manager authored a week ago
4876
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4877
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4878
      try {
4879
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4880
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4881
      } catch (e) {
4882
        if (!isAuthLost(e)) msg(e.message);
4883
      }
Xdev Host Manager authored a week ago
4884
    });
4885

            
Bogdan Timofte authored 4 days ago
4886
    refresh().catch(e => {
4887
      if (!isAuthLost(e)) showLogin(e.message);
4888
    });
Xdev Host Manager authored a week ago
4889
  </script>
4890
</body>
4891
</html>
4892
HTML
Bogdan Timofte authored 6 days ago
4893
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4894
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4895
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4896
    return $html;
Xdev Host Manager authored a week ago
4897
}