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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
725
sub delete_vhost {
726
    my ($client, $payload) = @_;
727
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
728
    my $confirm = normalize_dns_name($payload->{confirm} || '');
Bogdan Timofte authored 4 days ago
729
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
730
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
731

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

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

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

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

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

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

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

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

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

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

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

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

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

            
858
sub issue_vhost_certificate {
859
    my ($client, $payload) = @_;
860
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
Bogdan Timofte authored 4 days ago
861
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
862

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2307
    seed_mdns_observations_from_yaml($dbh);
2308
}
2309

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
2829
sub vhost_name_is_valid {
2830
    my ($name) = @_;
2831
    $name = normalize_dns_name($name);
2832
    return 0 unless length $name;
2833
    return 0 unless $name eq 'madagascar.xdev.ro' || $name =~ /\.madagascar\.xdev\.ro\z/;
2834
    return 0 unless length($name) <= 253;
2835
    for my $label (split /\./, $name) {
2836
        return 0 unless length($label) >= 1 && length($label) <= 63;
2837
        return 0 unless $label =~ /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/;
2838
    }
2839
    return 1;
2840
}
2841

            
Bogdan Timofte authored 4 days ago
2842
sub vhost_service_name {
2843
    my ($name) = @_;
2844
    $name = normalize_dns_name($name);
2845
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2846
    return '';
2847
}
2848

            
2849
sub short_alias_for_fqdn {
2850
    my ($name) = @_;
2851
    $name = normalize_dns_name($name);
2852
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2853
    return '';
2854
}
2855

            
Bogdan Timofte authored 4 days ago
2856
sub normalize_registry_policy {
2857
    my ($registry) = @_;
2858
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2859
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2860
    $registry->{policy}{runtime_database} = $opt{db};
2861
}
2862

            
2863
sub default_hosts_yaml {
2864
    return <<'YAML';
2865
version: 1
2866
updated_at: ""
2867
policy:
Bogdan Timofte authored 4 days ago
2868
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2869
hosts:
2870
YAML
2871
}
2872

            
2873
sub default_work_orders_yaml {
2874
    return <<'YAML';
2875
version: 1
2876
work_orders:
2877
YAML
2878
}
2879

            
2880
sub ensure_parent_dir {
2881
    my ($path) = @_;
2882
    my $dir = dirname($path);
2883
    make_path($dir) unless -d $dir;
2884
}
2885

            
Xdev Host Manager authored a week ago
2886
sub url_decode {
2887
    my ($value) = @_;
2888
    $value = '' unless defined $value;
2889
    $value =~ tr/+/ /;
2890
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2891
    return $value;
2892
}
2893

            
2894
sub random_hex {
2895
    my ($bytes) = @_;
2896
    if (open my $fh, '<:raw', '/dev/urandom') {
2897
        read($fh, my $raw, $bytes);
2898
        close $fh;
2899
        return unpack('H*', $raw);
2900
    }
2901
    return sha256_hex(rand() . time() . $$);
2902
}
2903

            
2904
sub iso_now {
2905
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2906
}
2907

            
Bogdan Timofte authored 6 days ago
2908
sub build_info {
2909
    my %info = (
2910
        revision => '',
2911
        branch => '',
2912
        built_at => '',
2913
        deployed_at => '',
2914
        dirty => '',
2915
    );
2916

            
2917
    if ($ENV{HOST_MANAGER_BUILD}) {
2918
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2919
        return \%info;
2920
    }
2921

            
2922
    my $build_file = "$project_dir/BUILD";
2923
    if (-f $build_file) {
2924
        for my $line (split /\n/, read_file($build_file)) {
2925
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2926
            $info{$1} = clean_scalar($2);
2927
        }
2928
        return \%info if $info{revision} || $info{built_at};
2929
    }
2930

            
2931
    my $revision = git_value('rev-parse --short=12 HEAD');
2932
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2933
    $info{revision} = $revision if $revision;
2934
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2935
    return \%info;
2936
}
2937

            
2938
sub git_value {
2939
    my ($args) = @_;
2940
    return '' unless -d "$project_dir/.git";
2941
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2942
    my $value = <$fh> || '';
2943
    close $fh;
2944
    chomp $value;
2945
    return clean_scalar($value);
2946
}
2947

            
2948
sub build_label {
2949
    my $info = build_info();
2950
    my $revision = $info->{revision} || 'unknown';
2951
    my $branch = $info->{branch} || '';
2952
    $branch = '' if $branch eq 'HEAD';
2953
    my $label = $branch ? "$branch $revision" : $revision;
2954
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2955
    return $label;
2956
}
2957

            
2958
sub build_title {
2959
    my $info = build_info();
2960
    my $label = build_label();
2961
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2962
    return $stamp ? "$label deployed $stamp" : $label;
2963
}
2964

            
Bogdan Timofte authored 5 days ago
2965
sub build_revision {
2966
    my $info = build_info();
2967
    return $info->{revision} || 'unknown';
2968
}
2969

            
2970
sub build_details {
2971
    my $info = build_info();
2972
    my %details = (
2973
        app => 'Madagascar Local Authority',
2974
        revision => $info->{revision} || 'unknown',
2975
        branch => $info->{branch} || '',
2976
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2977
        built_at => $info->{built_at} || '',
2978
        deployed_at => $info->{deployed_at} || '',
2979
        label => build_label(),
2980
        title => build_title(),
2981
    );
2982
    return json_encode(\%details);
2983
}
2984

            
Bogdan Timofte authored 6 days ago
2985
sub html_escape {
2986
    my ($value) = @_;
2987
    $value = '' unless defined $value;
2988
    $value =~ s/&/&amp;/g;
2989
    $value =~ s/</&lt;/g;
2990
    $value =~ s/>/&gt;/g;
2991
    $value =~ s/"/&quot;/g;
2992
    $value =~ s/'/&#039;/g;
2993
    return $value;
2994
}
2995

            
Xdev Host Manager authored a week ago
2996
sub app_html {
Bogdan Timofte authored 5 days ago
2997
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2998
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 5 days ago
2999
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
3000
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
3001
<!doctype html>
3002
<html lang="ro">
3003
<head>
3004
  <meta charset="utf-8">
3005
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
3006
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
3007
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
3008
  <style>
3009
    :root {
3010
      color-scheme: light;
3011
      --ink: #152033;
3012
      --muted: #647084;
3013
      --line: #d8dee8;
3014
      --soft: #f4f6f9;
3015
      --panel: #ffffff;
3016
      --accent: #1267d8;
3017
      --bad: #b42318;
3018
      --warn: #946200;
3019
      --ok: #137333;
3020
    }
3021
    * { box-sizing: border-box; }
3022
    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
3023

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

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

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

            
3342
  <!-- ── App (shown after login) ── -->
3343
  <div id="app">
3344
    <header>
Xdev Host Manager authored a week ago
3345
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 6 days ago
3346
      <nav aria-label="Sections">
3347
        <a href="/overview" data-page-link="overview">Overview</a>
3348
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3349
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 6 days ago
3350
        <a href="/dns" data-page-link="dns">DNS</a>
3351
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3352
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3353
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 6 days ago
3354
      </nav>
Xdev Host Manager authored a week ago
3355
      <div class="header-right">
3356
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 6 days ago
3357
        <span id="message" class="muted"></span>
3358
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3359
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3360
      </div>
Xdev Host Manager authored a week ago
3361
    </header>
3362
    <main>
Bogdan Timofte authored 6 days ago
3363
      <section class="page" id="page-overview" data-page="overview">
3364
        <section class="panel">
3365
          <div class="panel-head">
3366
            <h2>Overview</h2>
3367
            <div class="stats" id="stats"></div>
3368
          </div>
3369
          <div class="problems" id="problems"></div>
3370
        </section>
Xdev Host Manager authored a week ago
3371
      </section>
3372

            
Bogdan Timofte authored 6 days ago
3373
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3374
        <section class="panel">
3375
          <div class="panel-head">
3376
            <h2>Hosts</h2>
3377
            <div class="host-tools">
3378
              <input id="filter" placeholder="filter">
3379
              <button type="button" id="new-host">New host</button>
3380
            </div>
3381
          </div>
3382
          <div class="table-wrap">
3383
            <table>
3384
              <thead>
3385
                <tr>
Bogdan Timofte authored 4 days ago
3386
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 4 days ago
3387
                  <th>Aliases</th>
Bogdan Timofte authored 6 days ago
3388
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored 4 days ago
3389
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 6 days ago
3390
                  <th style="width: 110px">Monitoring</th>
3391
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3392
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 6 days ago
3393
                </tr>
3394
              </thead>
3395
              <tbody id="hosts"></tbody>
3396
            </table>
3397
          </div>
3398
        </section>
Xdev Host Manager authored a week ago
3399
      </section>
Xdev Host Manager authored a week ago
3400

            
Bogdan Timofte authored 4 days ago
3401
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3402
        <section class="panel">
3403
          <div class="panel-head">
3404
            <h2>Vhosts</h2>
3405
            <div class="host-tools">
3406
              <input id="vhost-filter" placeholder="filter">
3407
              <div class="stats" id="vhost-stats"></div>
3408
            </div>
3409
          </div>
Bogdan Timofte authored 4 days ago
3410
          <div class="vhost-inline-editor">
3411
            <input id="vhost-new-name" placeholder="vhost fqdn">
3412
            <select id="vhost-new-host"></select>
3413
            <button type="button" id="vhost-add">Add</button>
3414
          </div>
Bogdan Timofte authored 4 days ago
3415
          <div class="table-wrap">
3416
            <table>
3417
              <thead>
3418
                <tr>
Bogdan Timofte authored 4 days ago
3419
                  <th style="width: 22%">Vhost</th>
Bogdan Timofte authored 4 days ago
3420
                  <th style="width: 28%">Host</th>
3421
                  <th style="width: 34%">Certificate</th>
Bogdan Timofte authored 4 days ago
3422
                  <th style="width: 8%">Monitoring</th>
3423
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
3424
                </tr>
3425
              </thead>
3426
              <tbody id="vhosts"></tbody>
3427
            </table>
3428
          </div>
3429
        </section>
3430
      </section>
3431

            
Bogdan Timofte authored 6 days ago
3432
      <section class="page" id="page-dns" data-page="dns" hidden>
3433
        <section class="toolbar">
3434
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3435
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3436
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3437
          <button id="write-tsv">Write local-hosts.tsv</button>
3438
        </section>
Xdev Host Manager authored a week ago
3439
      </section>
3440

            
Bogdan Timofte authored 6 days ago
3441
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3442
        <section class="panel">
3443
          <div class="panel-head">
3444
            <h2>Work Orders</h2>
3445
            <div class="stats" id="wo-stats"></div>
3446
          </div>
3447
          <div class="problems" id="work-orders"></div>
3448
        </section>
Xdev Host Manager authored a week ago
3449
      </section>
3450

            
Bogdan Timofte authored 6 days ago
3451
      <section class="page" id="page-ca" data-page="ca" hidden>
3452
        <section class="panel">
3453
          <div class="panel-head">
3454
            <h2>Local Certificate Authority</h2>
3455
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3456
          </div>
3457
          <div class="problems" id="ca-status"></div>
3458
        </section>
3459
        <section class="panel">
3460
          <div class="panel-head">
3461
            <h2>Issued Certificates</h2>
3462
            <div class="stats" id="ca-certs-summary"></div>
3463
          </div>
3464
          <div class="table-wrap">
3465
            <table>
3466
              <thead>
3467
                <tr>
3468
                  <th style="width: 150px">Name</th>
3469
                  <th>DNS names</th>
3470
                  <th style="width: 210px">Validity</th>
3471
                  <th style="width: 180px">Serial</th>
3472
                  <th>Fingerprint</th>
3473
                  <th style="width: 110px">Download</th>
3474
                </tr>
3475
              </thead>
3476
              <tbody id="ca-certs"></tbody>
3477
            </table>
3478
          </div>
3479
        </section>
Xdev Host Manager authored a week ago
3480
      </section>
Bogdan Timofte authored 4 days ago
3481

            
3482
      <section class="page" id="page-debug" data-page="debug" hidden>
3483
        <section class="panel">
3484
          <div class="panel-head">
3485
            <h2>Database</h2>
3486
            <div class="stats" id="debug-db-stats"></div>
3487
          </div>
3488
          <div class="toolbar">
3489
            <div class="debug-controls">
3490
              <button type="button" id="debug-db-refresh">Refresh</button>
3491
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3492
            </div>
3493
          </div>
Bogdan Timofte authored 4 days ago
3494
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3495
        </section>
3496
        <section class="debug-section">
3497
          <section class="panel">
3498
            <div class="panel-head">
3499
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3500
              <div class="debug-table-head-actions">
3501
                <div class="stats" id="debug-table-stats"></div>
3502
                <div class="debug-table-exports">
3503
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3504
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3505
                </div>
3506
              </div>
Bogdan Timofte authored 4 days ago
3507
            </div>
3508
            <div class="table-wrap" id="debug-table-rows"></div>
3509
          </section>
3510
          <section class="panel">
3511
            <div class="panel-head">
3512
              <h2>Columns</h2>
3513
            </div>
3514
            <div class="table-wrap" id="debug-table-columns"></div>
3515
          </section>
3516
          <section class="panel">
3517
            <div class="panel-head">
3518
              <h2>Indexes</h2>
3519
            </div>
3520
            <div class="table-wrap" id="debug-table-indexes"></div>
3521
          </section>
3522
          <section class="panel">
3523
            <div class="panel-head">
3524
              <h2>Foreign Keys</h2>
3525
            </div>
3526
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3527
          </section>
3528
        </section>
3529
      </section>
Bogdan Timofte authored 6 days ago
3530
    </main>
Xdev Host Manager authored a week ago
3531

            
3532
  </div>
3533

            
Bogdan Timofte authored 5 days ago
3534
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3535
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3536
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3537
  </div>
Bogdan Timofte authored 6 days ago
3538

            
Xdev Host Manager authored a week ago
3539
  <script>
Bogdan Timofte authored 4 days ago
3540
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 6 days ago
3541
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3542
    let hostFormBusy = false;
3543
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3544
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3545

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

            
Bogdan Timofte authored 4 days ago
3598
    function isAuthLost(error) {
3599
      return !!(error && error.authLost);
3600
    }
3601

            
3602
    function authLostError(message) {
3603
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3604
      error.authLost = true;
3605
      return error;
3606
    }
3607

            
3608
    function handleAuthLost(message) {
3609
      state.authenticated = false;
3610
      msg('');
3611
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3612
    }
3613

            
Bogdan Timofte authored 4 days ago
3614
    async function ensureAuthenticated(message) {
3615
      if (!state.authenticated) {
3616
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3617
        return false;
3618
      }
3619
      const session = await api('/api/session');
3620
      state.authenticated = session.authenticated;
3621
      if (!state.authenticated) {
3622
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3623
        return false;
3624
      }
3625
      return true;
3626
    }
3627

            
Xdev Host Manager authored a week ago
3628
    async function api(path, options = {}) {
3629
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3630
      let body = {};
3631
      try {
3632
        body = await res.json();
3633
      } catch (_) {
3634
        body = {};
3635
      }
3636
      const errorCode = body.error || '';
3637
      if (!res.ok) {
3638
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3639
          const error = authLostError();
3640
          handleAuthLost(error.message);
3641
          throw error;
3642
        }
Bogdan Timofte authored 4 days ago
3643
        const error = new Error(body.detail || errorCode || res.statusText);
3644
        error.code = errorCode;
3645
        throw error;
Bogdan Timofte authored 4 days ago
3646
      }
Xdev Host Manager authored a week ago
3647
      return body;
3648
    }
3649

            
Bogdan Timofte authored 6 days ago
3650
    function currentPage() {
3651
      return PAGE_PATHS[window.location.pathname] || 'overview';
3652
    }
3653

            
3654
    function showPage(page, push = false) {
3655
      const target = page || 'overview';
3656
      document.querySelectorAll('[data-page]').forEach(section => {
3657
        section.hidden = section.dataset.page !== target;
3658
      });
3659
      document.querySelectorAll('[data-page-link]').forEach(link => {
3660
        link.classList.toggle('active', link.dataset.pageLink === target);
3661
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3662
      });
3663
      if (push) {
3664
        const href = target === 'overview' ? '/overview' : '/' + target;
3665
        history.pushState({ page: target }, '', href);
3666
      }
Bogdan Timofte authored 4 days ago
3667
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3668
        renderDebugDatabase().catch(e => {
3669
          if (!isAuthLost(e)) msg(e.message);
3670
        });
Bogdan Timofte authored 4 days ago
3671
      }
Bogdan Timofte authored 6 days ago
3672
    }
3673

            
Xdev Host Manager authored a week ago
3674
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3675
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3676
      document.body.classList.remove('is-app');
3677
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3678
      $('app').style.display = 'none';
3679
      $('login-screen').style.display = 'flex';
3680
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3681
      clearOtp();
Xdev Host Manager authored a week ago
3682
    }
3683

            
3684
    function showApp() {
Bogdan Timofte authored 6 days ago
3685
      document.body.classList.remove('is-login');
3686
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3687
      $('login-screen').style.display = 'none';
3688
      $('app').style.display = 'block';
Bogdan Timofte authored 6 days ago
3689
      showPage(currentPage());
Xdev Host Manager authored a week ago
3690
    }
3691

            
Xdev Host Manager authored a week ago
3692
    async function refresh() {
3693
      const session = await api('/api/session');
3694
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3695
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3696
      showApp();
Xdev Host Manager authored a week ago
3697
      const data = await api('/api/hosts');
3698
      state.hosts = data.hosts || [];
Bogdan Timofte authored 4 days ago
3699
      state.vhosts = data.vhosts || [];
3700
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3701
      state.problems = data.problems || [];
3702
      render(data);
Xdev Host Manager authored a week ago
3703
      await renderCa();
Xdev Host Manager authored a week ago
3704
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3705
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3706
    }
3707

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

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

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

            
3721
      renderHosts();
Bogdan Timofte authored 4 days ago
3722
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3723
      renderVhosts();
Xdev Host Manager authored a week ago
3724
    }
3725

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

            
Bogdan Timofte authored 6 days ago
3797
    function daysUntil(dateText) {
3798
      const time = Date.parse(dateText || '');
3799
      if (!Number.isFinite(time)) return null;
3800
      return Math.ceil((time - Date.now()) / 86400000);
3801
    }
3802

            
3803
    function certStatusClass(days) {
3804
      if (days === null) return '';
3805
      if (days < 0) return 'bad';
3806
      if (days <= 30) return 'warn';
3807
      return 'ok';
3808
    }
3809

            
3810
    function certStatusLabel(days) {
3811
      if (days === null) return 'validity unknown';
3812
      if (days < 0) return 'expired';
3813
      if (days === 0) return 'expires today';
3814
      return `${days}d remaining`;
3815
    }
3816

            
Xdev Host Manager authored a week ago
3817
    async function renderWorkOrders() {
3818
      try {
3819
        const data = await api('/api/work-orders');
3820
        state.workOrders = data.work_orders || [];
3821
        $('wo-stats').innerHTML = [
3822
          ['pending', data.counts.pending],
3823
          ['total', data.counts.work_orders],
3824
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3825

            
3826
        if (!state.workOrders.length) {
3827
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3828
          return;
3829
        }
3830

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

            
Bogdan Timofte authored 4 days ago
3870
    async function renderDebugDatabase() {
3871
      if (!state.authenticated) return;
3872
      const data = await api('/api/debug/database/tables');
3873
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3874
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3875
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3876
      $('debug-db-stats').innerHTML = [
3877
        ['tables', data.counts ? data.counts.tables : tables.length],
3878
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3879
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3880
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3881
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3882
      if (selected) {
3883
        await renderDebugTable(selected);
3884
      } else {
3885
        clearDebugTable();
3886
      }
3887
    }
3888

            
Bogdan Timofte authored 4 days ago
3889
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3890
      $('debug-db-tables').innerHTML = tables.length
3891
        ? tables.map(table => {
3892
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3893
            const ref = debugTableReference(database, table.name);
3894
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3895
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3896
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3897
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3898
              </button>
Bogdan Timofte authored 4 days ago
3899
              <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
3900
            </div>`;
Bogdan Timofte authored 4 days ago
3901
          }).join('')
3902
        : '<div class="ca-empty muted">No database tables found.</div>';
3903
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3904
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3905
          if (!isAuthLost(e)) msg(e.message);
3906
        }));
3907
      });
Bogdan Timofte authored 4 days ago
3908
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3909
        button.addEventListener('click', async () => {
3910
          try {
3911
            await copyText(button.dataset.debugTableRef || '');
3912
            msg('table reference copied');
3913
          } catch (e) {
3914
            msg('copy failed');
3915
          }
3916
        });
3917
      });
3918
    }
3919

            
3920
    function debugTableReference(database, tableName) {
3921
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3922
    }
3923

            
3924
    async function selectDebugTable(tableName) {
3925
      state.debugTable = tableName || '';
3926
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3927
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3928
        const card = button.closest('.debug-table-card');
3929
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3930
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3931
      });
3932
      if (state.debugTable) await renderDebugTable(state.debugTable);
3933
    }
3934

            
3935
    function clearDebugTable() {
3936
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3937
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3938
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3939
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3940
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3941
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3942
    }
3943

            
3944
    async function renderDebugTable(tableName) {
3945
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3946
      if (data.error) throw new Error(data.error);
3947
      $('debug-table-stats').innerHTML = [
3948
        ['table', data.table || tableName],
3949
        ['rows', data.row_count || 0],
3950
        ['shown', (data.rows || []).length],
3951
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3952
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3953
      renderDebugRows(data);
3954
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3955
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3956
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3957
    }
3958

            
Bogdan Timofte authored 4 days ago
3959
    function updateDebugExportLinks(tableName) {
3960
      const encoded = encodeURIComponent(tableName || '');
3961
      [
3962
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3963
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3964
      ].forEach(([id, href]) => {
3965
        const link = $(id);
3966
        const enabled = !!tableName;
3967
        link.href = enabled ? href : '#';
3968
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3969
      });
3970
    }
3971

            
Bogdan Timofte authored 4 days ago
3972
    function renderDebugRows(data) {
3973
      const rows = data.rows || [];
3974
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3975
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3976
    }
3977

            
3978
    function renderDebugObjectTable(rows, preferredKeys) {
3979
      const keys = preferredKeys && preferredKeys.length
3980
        ? preferredKeys
3981
        : Array.from(rows.reduce((set, row) => {
3982
            Object.keys(row || {}).forEach(key => set.add(key));
3983
            return set;
3984
          }, new Set()));
3985
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3986
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3987
      const body = rows.length
3988
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3989
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3990
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3991
    }
3992

            
3993
    function debugCell(value) {
3994
      if (value === null || value === undefined) return 'NULL';
3995
      if (Array.isArray(value)) return value.join(', ');
3996
      if (typeof value === 'object') return JSON.stringify(value);
3997
      return String(value);
3998
    }
3999

            
Xdev Host Manager authored a week ago
4000
    async function updateWorkOrderChecklist(id, itemId, checked) {
4001
      try {
4002
        await api('/api/work-orders/checklist', {
4003
          method: 'POST',
4004
          headers: { 'Content-Type': 'application/json' },
4005
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
4006
        });
4007
        msg('work order updated');
4008
        await refresh();
Bogdan Timofte authored 4 days ago
4009
      } catch (e) {
4010
        if (isAuthLost(e)) return;
4011
        msg(e.message);
4012
        await refresh().catch(refreshError => {
4013
          if (!isAuthLost(refreshError)) msg(refreshError.message);
4014
        });
4015
      }
Xdev Host Manager authored a week ago
4016
    }
4017

            
Xdev Host Manager authored a week ago
4018
    async function confirmWorkOrder(id) {
4019
      const typed = prompt(`Type ${id} to confirm this work order`);
4020
      if (typed !== id) return;
4021
      try {
4022
        await api('/api/work-orders/confirm', {
4023
          method: 'POST',
4024
          headers: { 'Content-Type': 'application/json' },
4025
          body: JSON.stringify({ id, confirm: typed })
4026
        });
4027
        msg('work order confirmed; local-hosts.tsv written');
4028
        await refresh();
Bogdan Timofte authored 4 days ago
4029
      } catch (e) {
4030
        if (isAuthLost(e)) return;
4031
        msg(e.message);
4032
      }
Xdev Host Manager authored a week ago
4033
    }
4034

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

            
Bogdan Timofte authored 4 days ago
4087
    function renderHostAliasCell(host) {
4088
      const canonical = host.fqdn ? `<span class="pill canonical host-alias-pill"><span class="host-alias-label">${escapeHtml(host.fqdn)}</span></span>` : '';
4089
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4090
        <span class="host-alias-label">${escapeHtml(name)}</span>
4091
        <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>
4092
      </span>`).join('');
4093
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived host-alias-pill" title="derived alias">
4094
        <span class="host-alias-label">${escapeHtml(name)}</span>
4095
      </span>`).join('');
4096
      return `<div class="host-alias-cell">
4097
        <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>
4098
      </div>`;
4099
    }
4100

            
4101
    function renderHostCertificateCell(host) {
4102
      const cert = host.certificate || {};
Bogdan Timofte authored 4 days ago
4103
      const certId = host.certificate_id || certificateIdOf(cert) || '';
Bogdan Timofte authored 4 days ago
4104
      const row = hostCertificateRow(host);
4105
      const links = certId ? `<div class="vhost-cert-links">
4106
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4107
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4108
      </div>` : '';
4109
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4110
      return `<div class="vhost-cert">
4111
        <div class="vhost-cert-main">
4112
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4113
            ${renderCertificateOptions(certId, row)}
4114
          </select>
4115
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4116
        </div>
4117
        <div class="vhost-cert-meta">${links}${validity}</div>
4118
      </div>`;
4119
    }
4120

            
4121
    function hostCertificateRow(host) {
4122
      return {
4123
        host_fqdn: host.fqdn || '',
4124
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4125
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4126
        certificate_id: host.certificate_id || '',
4127
        certificate: host.certificate || null,
4128
      };
Bogdan Timofte authored 4 days ago
4129
    }
4130

            
4131
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4132
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4133
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4134
        vhost,
4135
        host_id: host.id || '',
4136
        host_fqdn: host.fqdn || '',
4137
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4138
        monitoring: host.monitoring || '',
4139
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4140
        certificate_id: '',
4141
        certificate: null,
Bogdan Timofte authored 4 days ago
4142
      })));
4143
    }
4144

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

            
Bogdan Timofte authored 4 days ago
4200
    function renderVhostNameCell(row) {
4201
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4202
      return `<div class="vhost-name-cell">
4203
        <div class="vhost-name-main">
4204
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4205
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4206
        </div>
4207
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4208
      </div>`;
4209
    }
4210

            
Bogdan Timofte authored 4 days ago
4211
    function renderVhostCertificateCell(row) {
4212
      const cert = row.certificate || {};
4213
      const certId = row.certificate_id || cert.id || cert.name || '';
4214
      const links = certId ? `<div class="vhost-cert-links">
4215
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4216
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4217
      </div>` : '';
4218
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4219
      return `<div class="vhost-cert">
4220
        <div class="vhost-cert-main">
4221
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 4 days ago
4222
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 4 days ago
4223
          </select>
4224
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4225
        </div>
4226
        <div class="vhost-cert-meta">${links}${validity}</div>
4227
      </div>`;
Bogdan Timofte authored 4 days ago
4228
    }
4229

            
4230
    function renderVhostEditor() {
4231
      const select = $('vhost-new-host');
4232
      const current = select.value || '';
4233
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4234
    }
4235

            
4236
    function renderVhostHostOptions(selectedHostFqdn) {
4237
      return state.hosts
4238
        .slice()
4239
        .filter(host => (host.status || '') !== 'retired')
4240
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4241
        .map(host => {
4242
          const fqdn = host.fqdn || '';
4243
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4244
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4245
        }).join('');
Bogdan Timofte authored 4 days ago
4246
    }
4247

            
Bogdan Timofte authored 4 days ago
4248
    function renderCertificateOptions(selectedCertificateId, row) {
4249
      const byId = new Map();
4250
      (state.certificates || []).forEach(cert => {
Bogdan Timofte authored 4 days ago
4251
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4252
        if (id) byId.set(id, cert);
4253
      });
4254
      if (row && row.certificate) {
Bogdan Timofte authored 4 days ago
4255
        const id = certificateIdOf(row.certificate);
Bogdan Timofte authored 4 days ago
4256
        if (id && !byId.has(id)) byId.set(id, row.certificate);
4257
      }
4258
      const certs = Array.from(byId.values())
Bogdan Timofte authored 4 days ago
4259
        .filter(cert => certMatchesRow(cert, row) || certificateIdOf(cert) === selectedCertificateId)
Bogdan Timofte authored 4 days ago
4260
        .sort((a, b) => {
4261
          const ar = certRelevance(a, row);
4262
          const br = certRelevance(b, row);
4263
          if (ar !== br) return ar - br;
4264
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4265
        });
Bogdan Timofte authored 4 days ago
4266
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 4 days ago
4267
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4268
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 4 days ago
4269
        const selected = id === selectedCertificateId ? ' selected' : '';
4270
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
4271
      }));
4272
      return options.join('');
4273
    }
4274

            
Bogdan Timofte authored 4 days ago
4275
    function certificateIdOf(cert) {
Bogdan Timofte authored 4 days ago
4276
      return cert ? (cert.id || cert.name || '') : '';
4277
    }
4278

            
4279
    function certDnsNames(cert) {
4280
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4281
        .map(name => String(name || '').toLowerCase())
4282
        .filter(Boolean);
4283
    }
4284

            
4285
    function certRelevance(cert, row) {
4286
      if (!row) return 9;
4287
      const names = new Set(certDnsNames(cert));
Bogdan Timofte authored 4 days ago
4288
      const id = String(certificateIdOf(cert)).toLowerCase();
Bogdan Timofte authored 4 days ago
4289
      const commonName = String(cert.common_name || '').toLowerCase();
4290
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4291
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4292
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 4 days ago
4293
      const aliasNames = []
4294
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4295
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4296
        .map(name => String(name || '').toLowerCase())
4297
        .filter(Boolean);
4298
      if (vhost) {
4299
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4300
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4301
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4302
        return 9;
4303
      }
4304
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4305
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 4 days ago
4306
      return 9;
4307
    }
4308

            
4309
    function certMatchesRow(cert, row) {
4310
      return certRelevance(cert, row) < 9;
4311
    }
4312

            
4313
    function compactCertificateLabel(cert, row) {
4314
      const relevance = certRelevance(cert, row);
4315
      const days = daysUntil(cert.not_after);
4316
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 4 days ago
4317
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 4 days ago
4318
      if (row && row.vhost) {
Bogdan Timofte authored 4 days ago
4319
        if (relevance === 0) return `${name}${suffix}`;
4320
        if (relevance === 1) return `host ${name}${suffix}`;
4321
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4322
      } else {
Bogdan Timofte authored 4 days ago
4323
        if (relevance === 0) return `${name}${suffix}`;
4324
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4325
      }
Bogdan Timofte authored 4 days ago
4326
      return `${shortCertificateName(cert)}${suffix}`;
4327
    }
4328

            
Bogdan Timofte authored 4 days ago
4329
    function certificateDisplayName(cert) {
4330
      const commonName = String(cert.common_name || '').trim();
4331
      if (commonName) return commonName;
4332
      const dnsNames = certDnsNames(cert);
4333
      if (dnsNames.length) return dnsNames[0];
4334
      return shortCertificateName(cert);
4335
    }
4336

            
Bogdan Timofte authored 4 days ago
4337
    function shortCertificateName(cert) {
4338
      const name = String(cert.common_name || cert.name || cert.id || '');
4339
      const suffix = '.madagascar.xdev.ro';
4340
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4341
    }
4342

            
Bogdan Timofte authored 4 days ago
4343
    function shortAliasForFqdn(name) {
4344
      const suffix = '.madagascar.xdev.ro';
4345
      name = String(name || '').toLowerCase();
4346
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 5 days ago
4347
    }
4348

            
Bogdan Timofte authored 4 days ago
4349
    function hostByFqdn(fqdn) {
4350
      fqdn = String(fqdn || '').toLowerCase();
4351
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4352
    }
4353

            
4354
    function hostUpsertPayload(host, overrides = {}) {
4355
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4356
      const payload = {
4357
        id: host.id || '',
4358
        fqdn: host.fqdn || '',
4359
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4360
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4361
        aliases,
4362
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
4363
        sources: Array.isArray(overrides.sources) ? overrides.sources : (host.sources || []),
4364
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4365
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4366
      };
4367
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4368
      return payload;
4369
    }
4370

            
4371
    async function addHostAlias(hostFqdn) {
4372
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4373
      const host = hostByFqdn(hostFqdn);
4374
      if (!host) return;
4375
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4376
      if (!alias) return;
4377
      if (alias === String(host.fqdn || '').toLowerCase()) {
4378
        msg('fqdn-ul hostului este deja prezent');
4379
        return;
4380
      }
4381
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4382
      await api('/api/hosts/upsert', {
4383
        method: 'POST',
4384
        headers: { 'Content-Type': 'application/json' },
4385
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4386
      });
4387
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4388
      await refresh();
4389
    }
4390

            
4391
    async function removeHostAlias(hostFqdn, alias) {
4392
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4393
      const host = hostByFqdn(hostFqdn);
4394
      alias = String(alias || '').trim().toLowerCase();
4395
      if (!host || !alias) return;
4396
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4397
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4398
      await api('/api/hosts/upsert', {
4399
        method: 'POST',
4400
        headers: { 'Content-Type': 'application/json' },
4401
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4402
      });
4403
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4404
      await refresh();
4405
    }
4406

            
4407
    async function setHostCertificateFromSelect(select) {
4408
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4409
        select.value = select.dataset.currentCertificate || '';
4410
        return;
4411
      }
4412
      const hostFqdn = select.dataset.hostCertSelect || '';
4413
      const certificateId = select.value || '';
4414
      const current = select.dataset.currentCertificate || '';
4415
      if (!hostFqdn || certificateId === current) return;
4416
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4417
        select.value = current;
4418
        return;
4419
      }
4420
      select.disabled = true;
4421
      try {
4422
        await api('/api/hosts/certificate', {
4423
          method: 'POST',
4424
          headers: { 'Content-Type': 'application/json' },
4425
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4426
        });
4427
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4428
        await refresh();
4429
      } finally {
4430
        select.disabled = false;
4431
      }
4432
    }
4433

            
4434
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4435
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4436
      if (!hostFqdn) return;
4437
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4438
      if (button) button.disabled = true;
4439
      try {
4440
        const result = await api('/api/hosts/issue-certificate', {
4441
          method: 'POST',
4442
          headers: { 'Content-Type': 'application/json' },
4443
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4444
        });
4445
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4446
        await refresh();
4447
      } finally {
4448
        if (button) button.disabled = false;
4449
      }
4450
    }
4451

            
Bogdan Timofte authored 4 days ago
4452
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4453
      const vhost = select.dataset.vhostSelect || '';
4454
      const fromHost = select.dataset.currentHost || '';
4455
      const toHost = select.value || '';
4456
      if (!vhost || !toHost || toHost === fromHost) return;
4457
      select.disabled = true;
4458
      try {
4459
        await api('/api/vhosts/reassign', {
4460
          method: 'POST',
4461
          headers: { 'Content-Type': 'application/json' },
4462
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4463
        });
4464
        msg(`vhost ${vhost} moved`);
4465
        await refresh();
4466
      } finally {
4467
        select.disabled = false;
4468
      }
4469
    }
4470

            
Bogdan Timofte authored 4 days ago
4471
    async function addVhostInline() {
4472
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4473
      const nameInput = $('vhost-new-name');
4474
      const hostSelect = $('vhost-new-host');
4475
      const vhost = (nameInput.value || '').trim().toLowerCase();
4476
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 4 days ago
4477
      if (!vhost || !hostFqdn) {
4478
        msg('completeaza vhost si host');
4479
        return;
4480
      }
4481
      if (!isValidVhostName(vhost)) {
4482
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4483
        nameInput.focus();
4484
        return;
4485
      }
4486
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4487
        msg('vhost invalid: numele este deja host real');
4488
        nameInput.focus();
4489
        return;
4490
      }
Bogdan Timofte authored 4 days ago
4491
      $('vhost-add').disabled = true;
4492
      nameInput.disabled = true;
4493
      hostSelect.disabled = true;
4494
      try {
4495
        await api('/api/vhosts/upsert', {
4496
          method: 'POST',
4497
          headers: { 'Content-Type': 'application/json' },
4498
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4499
        });
4500
        nameInput.value = '';
4501
        msg(`vhost ${vhost} saved`);
4502
        await refresh();
Bogdan Timofte authored 4 days ago
4503
      } catch (e) {
4504
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4505
      } finally {
4506
        $('vhost-add').disabled = false;
4507
        nameInput.disabled = false;
4508
        hostSelect.disabled = false;
4509
      }
4510
    }
4511

            
Bogdan Timofte authored 4 days ago
4512
    function isValidVhostName(name) {
4513
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4514
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4515
      if (name.length > 253) return false;
4516
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4517
    }
4518

            
4519
    function vhostErrorMessage(error) {
4520
      const code = error && error.code ? error.code : '';
4521
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4522
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4523
      if (code === 'invalid_target_host') return 'host tinta invalid';
4524
      if (code === 'missing_target_host') return 'alege hostul tinta';
4525
      return error && error.message ? error.message : 'vhost add failed';
4526
    }
4527

            
Bogdan Timofte authored 4 days ago
4528
    async function setVhostCertificateFromSelect(select) {
4529
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4530
        select.value = select.dataset.currentCertificate || '';
4531
        return;
4532
      }
4533
      const vhost = select.dataset.vhostCertSelect || '';
4534
      const certificateId = select.value || '';
4535
      const current = select.dataset.currentCertificate || '';
4536
      if (!vhost || certificateId === current) return;
4537
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4538
        select.value = current;
4539
        return;
4540
      }
4541
      select.disabled = true;
4542
      try {
4543
        await api('/api/vhosts/certificate', {
4544
          method: 'POST',
4545
          headers: { 'Content-Type': 'application/json' },
4546
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4547
        });
4548
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4549
        await refresh();
4550
      } finally {
4551
        select.disabled = false;
4552
      }
4553
    }
4554

            
Bogdan Timofte authored 4 days ago
4555
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4556
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4557
      if (!vhost) return;
4558
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4559
      if (button) button.disabled = true;
4560
      try {
4561
        const result = await api('/api/vhosts/issue-certificate', {
4562
          method: 'POST',
4563
          headers: { 'Content-Type': 'application/json' },
4564
          body: JSON.stringify({ vhost_fqdn: vhost }),
4565
        });
4566
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4567
        await refresh();
4568
      } finally {
4569
        if (button) button.disabled = false;
4570
      }
4571
    }
4572

            
Bogdan Timofte authored 4 days ago
4573
    async function deleteVhostInline(vhost) {
4574
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4575
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4576
      await api('/api/vhosts/delete', {
4577
        method: 'POST',
4578
        headers: { 'Content-Type': 'application/json' },
4579
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4580
      });
4581
      msg(`vhost ${vhost} deleted`);
4582
      await refresh();
4583
    }
4584

            
Bogdan Timofte authored 4 days ago
4585
    async function editHost(id) {
4586
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4587
      const host = state.hosts.find(h => h.id === id);
4588
      if (!host) return;
Bogdan Timofte authored 4 days ago
4589
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 6 days ago
4590
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4591
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4592
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 6 days ago
4593
      hostField('roles').value = (host.roles || []).join(' ');
4594
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4595
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 6 days ago
4596
    }
4597

            
Bogdan Timofte authored 4 days ago
4598
    async function newHost() {
4599
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4600
      if (!canSwitchHostEditor('__new__')) return;
4601
      resetHostForm(true);
4602
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 6 days ago
4603
    }
4604

            
Bogdan Timofte authored 4 days ago
4605
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4606
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4607
      hostEditorTarget = target || '';
4608
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4609
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4610
      renderHosts();
4611
      hostFormSnapshot = hostFormState();
4612
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4613
      hostField(focusField).focus();
Bogdan Timofte authored 6 days ago
4614
    }
4615

            
Bogdan Timofte authored 4 days ago
4616
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4617
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4618
      hostForm.reset();
Bogdan Timofte authored 6 days ago
4619
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4620
      hostField('status').value = 'active';
4621
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4622
      hostFormSnapshot = force ? '' : hostFormState();
4623
    }
4624

            
4625
    function closeHostForm(force = false) {
4626
      if (hostFormBusy && !force) return;
4627
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4628
      hostEditorTarget = '';
4629
      hostFormMode = 'new';
4630
      hostFormSnapshot = '';
4631
      clearHostFormMessage();
4632
      syncHostFormActions();
4633
      mountHostEditor();
4634
    }
4635

            
4636
    function canSwitchHostEditor(target) {
4637
      if (hostFormBusy) return false;
4638
      if (!hostEditorTarget) return true;
4639
      if (!hostFormDirty()) return true;
4640
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4641
      return confirm('Discard unsaved host changes?');
4642
    }
4643

            
4644
    function mountHostEditor() {
4645
      hostEditorRow.remove();
4646
      if (!hostEditorTarget) {
4647
        hostFormShell.hidden = true;
4648
        return;
4649
      }
4650
      hostEditorCell.colSpan = 7;
4651
      const tbody = $('hosts');
4652
      if (!tbody) return;
4653
      if (hostEditorTarget === '__new__') {
4654
        tbody.prepend(hostEditorRow);
4655
      } else {
4656
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4657
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4658
        if (targetRow) targetRow.after(hostEditorRow);
4659
        else tbody.prepend(hostEditorRow);
4660
      }
4661
      hostFormShell.hidden = false;
Bogdan Timofte authored 6 days ago
4662
    }
4663

            
4664
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4665
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 6 days ago
4666
    }
4667

            
4668
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4669
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 6 days ago
4670
    }
4671

            
4672
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4673
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 6 days ago
4674
    }
4675

            
4676
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4677
      hostFormBusy = !!busy;
4678
      syncHostFormActions();
4679
    }
4680

            
4681
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4682
      saveHostButton.disabled = hostFormBusy;
4683
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4684
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 6 days ago
4685
    }
4686

            
4687
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4688
      hostFormMessage.textContent = text || '';
4689
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 6 days ago
4690
    }
4691

            
4692
    function clearHostFormMessage() {
4693
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4694
    }
4695

            
4696
    function formObject(form) {
4697
      return Object.fromEntries(new FormData(form).entries());
4698
    }
4699

            
4700
    function escapeHtml(value) {
Bogdan Timofte authored 6 days ago
4701
      value = value == null ? '' : String(value);
Xdev Host Manager authored a week ago
4702
      return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
4703
    }
4704

            
Bogdan Timofte authored 6 days ago
4705
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4706

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

            
4712
    if (loginAccount) {
4713
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4714
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4715
      loginAccount.addEventListener('input', () => {
4716
        const value = (loginAccount.value || '').trim();
4717
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4718
      });
4719
    }
4720

            
Xdev Host Manager authored a week ago
4721
    function setOtpDigit(idx, value) {
4722
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
4723
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4724
      otpDigits[idx].classList.toggle('filled', !!digit);
4725
    }
4726

            
Bogdan Timofte authored 5 days ago
4727
    // Move focus to the next empty box: forward from idx, then wrapping to the
4728
    // start. This lets out-of-order entry continue (e.g. after the last box,
4729
    // jump back to the first still-empty box). Stays put when all boxes are full.
4730
    function advanceFocus(idx) {
4731
      for (let i = idx + 1; i < otpDigits.length; i++) {
4732
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4733
      }
4734
      for (let i = 0; i <= idx; i++) {
4735
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4736
      }
4737
    }
4738

            
Bogdan Timofte authored 5 days ago
4739
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4740
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4741
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
4742
      const digits = (text || '').replace(/\D/g, '').split('');
4743
      if (!digits.length) return;
4744
      let last = startIdx;
4745
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4746
        last = startIdx + i;
4747
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4748
      }
Bogdan Timofte authored 5 days ago
4749
      syncOtpFields();
Bogdan Timofte authored 5 days ago
4750
      advanceFocus(last);
Xdev Host Manager authored a week ago
4751
      maybeSubmitOtp();
4752
    }
4753

            
Bogdan Timofte authored 5 days ago
4754
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4755
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4756
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4757
    function maybeSubmitOtp() {
4758
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4759
    }
4760
    function clearOtp() {
Bogdan Timofte authored 5 days ago
4761
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4762
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 5 days ago
4763
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4764
      // an unknown operator, so Safari's autofill anchor on the username stays.
4765
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4766
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4767
    }
4768

            
Bogdan Timofte authored 5 days ago
4769
    otpDigits.forEach((input, idx) => {
4770
      input.addEventListener('input', () => {
Bogdan Timofte authored 5 days ago
4771
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4772
        // A single box may receive several digits at once (autofill / typing fast).
4773
        if (input.value.replace(/\D/g, '').length > 1) {
4774
          fillOtp(input.value, idx);
4775
          return;
4776
        }
4777
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
4778
        syncOtpFields();
Bogdan Timofte authored 5 days ago
4779
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
4780
        maybeSubmitOtp();
4781
      });
Bogdan Timofte authored 5 days ago
4782

            
4783
      input.addEventListener('paste', (e) => {
4784
        e.preventDefault();
Bogdan Timofte authored 5 days ago
4785
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4786
        const text = (e.clipboardData || window.clipboardData).getData('text');
4787
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
4788
      });
Bogdan Timofte authored 5 days ago
4789

            
4790
      input.addEventListener('keydown', (e) => {
4791
        if (e.key === 'Backspace') {
4792
          e.preventDefault();
Bogdan Timofte authored 5 days ago
4793
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4794
          if (input.value) { setOtpDigit(idx, ''); }
4795
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4796
          syncOtpFields();
4797
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4798
          e.preventDefault();
4799
          otpDigits[idx - 1].focus();
4800
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4801
          e.preventDefault();
4802
          otpDigits[idx + 1].focus();
4803
        }
4804
      });
4805
    });
4806

            
Bogdan Timofte authored 5 days ago
4807
    // Focus the first OTP box only for a returning operator (username known).
4808
    // For an unknown operator, leave focus on the username field so Safari can
4809
    // present its OTP autofill anchored there without being dismissed by a focus
4810
    // change (pbx-admin pattern).
4811
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4812
    else if (loginAccount) loginAccount.focus();
4813
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4814

            
Bogdan Timofte authored 6 days ago
4815
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4816
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 6 days ago
4817
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4818
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 6 days ago
4819
        showPage(link.dataset.pageLink, true);
4820
      });
4821
    });
4822

            
Bogdan Timofte authored 4 days ago
4823
    window.addEventListener('popstate', () => {
4824
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4825
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4826
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4827
    });
Bogdan Timofte authored 6 days ago
4828

            
Bogdan Timofte authored 5 days ago
4829
    async function copyText(text) {
4830
      if (navigator.clipboard && window.isSecureContext) {
4831
        await navigator.clipboard.writeText(text);
4832
        return;
4833
      }
4834
      const input = document.createElement('textarea');
4835
      input.value = text;
4836
      input.setAttribute('readonly', '');
4837
      input.style.position = 'fixed';
4838
      input.style.left = '-10000px';
4839
      document.body.appendChild(input);
4840
      input.select();
4841
      document.execCommand('copy');
4842
      document.body.removeChild(input);
4843
    }
4844

            
4845
    $('copy-build').addEventListener('click', async () => {
4846
      try {
4847
        await copyText($('copy-build').dataset.buildDetails || '');
4848
        if (state.authenticated) msg('build details copied');
4849
      } catch (e) {
4850
        if (state.authenticated) msg('copy failed');
4851
      }
4852
    });
4853

            
Xdev Host Manager authored a week ago
4854
    $('login-form').addEventListener('submit', async (event) => {
4855
      event.preventDefault();
Bogdan Timofte authored 5 days ago
4856
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4857
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4858
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4859
      try {
Xdev Host Manager authored a week ago
4860
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4861
        await refresh();
Xdev Host Manager authored a week ago
4862
      } catch (e) {
4863
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4864
      } finally {
Xdev Host Manager authored a week ago
4865
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4866
      }
Xdev Host Manager authored a week ago
4867
    });
4868

            
4869
    $('logout').addEventListener('click', async () => {
4870
      await api('/api/logout', { method: 'POST' }).catch(() => {});
Bogdan Timofte authored 6 days ago
4871
      window.location.replace('/?logged_out=' + Date.now());
Xdev Host Manager authored a week ago
4872
    });
4873

            
Bogdan Timofte authored 4 days ago
4874
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4875
      if (!isAuthLost(e)) msg(e.message);
4876
    }));
Xdev Host Manager authored a week ago
4877
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4878
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4879
    $('vhost-add').addEventListener('click', () => {
4880
      addVhostInline().catch(e => {
4881
        if (!isAuthLost(e)) msg(e.message);
4882
      });
4883
    });
4884
    $('vhost-new-name').addEventListener('keydown', (event) => {
4885
      if (event.key !== 'Enter') return;
4886
      event.preventDefault();
4887
      addVhostInline().catch(e => {
4888
        if (!isAuthLost(e)) msg(e.message);
4889
      });
4890
    });
Bogdan Timofte authored 4 days ago
4891
    $('new-host').addEventListener('click', () => {
4892
      newHost().catch(e => {
4893
        if (!isAuthLost(e)) msg(e.message);
4894
      });
4895
    });
Bogdan Timofte authored 4 days ago
4896
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4897
      if (!isAuthLost(e)) msg(e.message);
4898
    }));
Bogdan Timofte authored 4 days ago
4899
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4900

            
Bogdan Timofte authored 4 days ago
4901
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4902
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4903
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 6 days ago
4904
      setHostFormBusy(true);
4905
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4906
      try {
Bogdan Timofte authored 4 days ago
4907
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4908
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4909
        msg('host saved');
4910
        await refresh();
Bogdan Timofte authored 4 days ago
4911
        const host = state.hosts.find(entry => entry.id === savedId);
4912
        if (host) {
4913
          clearHostFormMessage();
4914
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4915
          hostField('aliases').value = (host.aliases || []).join('\n');
4916
          hostField('roles').value = (host.roles || []).join(' ');
4917
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4918
          activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 4 days ago
4919
        } else {
Bogdan Timofte authored 4 days ago
4920
          closeHostForm(true);
Bogdan Timofte authored 4 days ago
4921
        }
Bogdan Timofte authored 6 days ago
4922
      } catch (e) {
Bogdan Timofte authored 4 days ago
4923
        if (isAuthLost(e)) return;
Bogdan Timofte authored 6 days ago
4924
        setHostFormMessage(e.message, true);
4925
        msg(e.message);
4926
      } finally {
4927
        setHostFormBusy(false);
4928
      }
4929
    });
4930

            
Bogdan Timofte authored 4 days ago
4931
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 6 days ago
4932
      setHostFormMessage('Complete the required host fields before saving.', true);
4933
    }, true);
4934

            
Bogdan Timofte authored 4 days ago
4935
    hostForm.addEventListener('input', () => {
4936
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4937
    });
4938

            
Bogdan Timofte authored 4 days ago
4939
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 6 days ago
4940
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4941
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 6 days ago
4942
      setHostFormBusy(true);
4943
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4944
      try {
4945
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4946
        msg('host deleted');
4947
        await refresh();
Bogdan Timofte authored 4 days ago
4948
        closeHostForm(true);
Bogdan Timofte authored 6 days ago
4949
      } catch (e) {
Bogdan Timofte authored 4 days ago
4950
        if (isAuthLost(e)) return;
Bogdan Timofte authored 6 days ago
4951
        setHostFormMessage(e.message, true);
4952
        msg(e.message);
4953
      } finally {
4954
        setHostFormBusy(false);
4955
      }
Xdev Host Manager authored a week ago
4956
    });
4957

            
Bogdan Timofte authored 4 days ago
4958
    resetHostForm(true);
4959
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4960

            
Xdev Host Manager authored a week ago
4961
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4962
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4963
      try {
4964
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4965
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4966
      } catch (e) {
4967
        if (!isAuthLost(e)) msg(e.message);
4968
      }
Xdev Host Manager authored a week ago
4969
    });
4970

            
Bogdan Timofte authored 4 days ago
4971
    refresh().catch(e => {
4972
      if (!isAuthLost(e)) showLogin(e.message);
4973
    });
Xdev Host Manager authored a week ago
4974
  </script>
4975
</body>
4976
</html>
4977
HTML
Bogdan Timofte authored 6 days ago
4978
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4979
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 5 days ago
4980
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4981
    return $html;
Xdev Host Manager authored a week ago
4982
}