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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Xdev Host Manager authored a week ago
427
sub registry_payload {
428
    my ($registry) = @_;
429
    my $problems = analyze_hosts($registry->{hosts});
Bogdan Timofte authored 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 3 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 3 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 3 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 3 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 3 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 3 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 3 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 3 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 3 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 3 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 5 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 3 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 4 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 4 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 4 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 4 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 4 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 4 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 4 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 4 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 4 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 4 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 4 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 4 days ago
3122
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
3123
      }
Bogdan Timofte authored 4 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 4 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 5 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 5 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 5 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 4 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 5 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 4 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 4 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 4 days ago
3196
    }
3197
    .build-badge {
3198
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
3199
      cursor: text;
3200
      user-select: text;
Bogdan Timofte authored 6 days ago
3201
    }
Bogdan Timofte authored 4 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 4 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 5 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; }
Bogdan Timofte authored 3 days ago
3256
    .iconbtn { min-width: 34px; width: 34px; justify-content: center; padding: 7px; }
3257
    .iconbtn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
3258
    .host-actions { display: flex; align-items: center; gap: 6px; }
Bogdan Timofte authored 3 days ago
3259
    .field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
Bogdan Timofte authored 4 days ago
3260
    .host-cert-cell { min-width: 0; }
Bogdan Timofte authored 4 days ago
3261
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3262
    #page-vhosts .host-tools { flex-wrap: wrap; }
3263
    #page-vhosts .host-tools input { max-width: 280px; }
3264
    #page-vhosts .stats { justify-content: flex-end; }
Bogdan Timofte authored 4 days ago
3265
    #page-vhosts .table-wrap { overflow-x: visible; }
3266
    #page-vhosts table { min-width: 0; }
Bogdan Timofte authored 4 days ago
3267
    #page-vhosts th, #page-vhosts td { overflow-wrap: normal; }
3268
    #page-vhosts .pill.vhost { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; }
Bogdan Timofte authored 4 days ago
3269
    .vhost-name-cell { display: grid; gap: 5px; min-width: 0; }
3270
    .vhost-name-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-width: 0; }
3271
    .vhost-delete { min-height: 28px; padding: 3px 7px; color: var(--bad); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3272
    .vhost-host { display: grid; gap: 2px; }
3273
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
3274
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
3275
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 4 days ago
3276
    .vhost-cert { display: grid; gap: 5px; min-width: 0; }
3277
    .vhost-cert-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; }
3278
    .vhost-cert-select { width: 100%; max-width: 100%; min-height: 34px; }
3279
    .vhost-cert-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
3280
    .vhost-cert-links { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
3281
    .vhost-cert-links .linkbtn { padding: 3px 7px; font-size: 12px; }
3282
    .vhost-cert-validity { font-size: 12px; }
Bogdan Timofte authored 4 days ago
3283
    .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
3284
    .host-inline-row td { padding: 0; background: #fff; }
3285
    .host-inline-editor-shell { background: #fff; }
3286
    .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; }
3287
    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3288
    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
3289
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
3290
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
3291
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
3292
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
3293
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
3294
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
3295
      #message { max-width: 100%; }
3296
      .panel-head { align-items: stretch; flex-direction: column; }
3297
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
3298
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
3299
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 4 days ago
3300
      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3301
      .host-inline-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
3302
      .debug-controls { align-items: stretch; }
Xdev Host Manager authored a week ago
3303
      .grid { grid-template-columns: 1fr; }
3304
      table { min-width: 760px; }
3305
      .table-wrap { overflow-x: auto; }
3306
    }
3307
  </style>
3308
</head>
Bogdan Timofte authored 6 days ago
3309
<body class="is-login">
Xdev Host Manager authored a week ago
3310

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

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

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

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

            
Bogdan Timofte authored 5 days ago
3437
      <section class="page" id="page-dns" data-page="dns" hidden>
3438
        <section class="toolbar">
3439
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3440
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3441
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3442
          <button id="write-tsv">Write local-hosts.tsv</button>
3443
        </section>
Xdev Host Manager authored a week ago
3444
      </section>
3445

            
Bogdan Timofte authored 5 days ago
3446
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3447
        <section class="panel">
3448
          <div class="panel-head">
3449
            <h2>Work Orders</h2>
3450
            <div class="stats" id="wo-stats"></div>
3451
          </div>
3452
          <div class="problems" id="work-orders"></div>
3453
        </section>
Xdev Host Manager authored a week ago
3454
      </section>
3455

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

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

            
3537
  </div>
3538

            
Bogdan Timofte authored 4 days ago
3539
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3540
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3541
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3542
  </div>
Bogdan Timofte authored 6 days ago
3543

            
Xdev Host Manager authored a week ago
3544
  <script>
Bogdan Timofte authored 4 days ago
3545
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3546
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3547
    let hostFormBusy = false;
3548
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3549
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3550

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

            
Bogdan Timofte authored 4 days ago
3601
    function isAuthLost(error) {
3602
      return !!(error && error.authLost);
3603
    }
3604

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

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

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

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

            
Bogdan Timofte authored 5 days ago
3653
    function currentPage() {
3654
      return PAGE_PATHS[window.location.pathname] || 'overview';
3655
    }
3656

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

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

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

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

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

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

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

            
3724
      renderHosts();
Bogdan Timofte authored 4 days ago
3725
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3726
      renderVhosts();
Xdev Host Manager authored a week ago
3727
    }
3728

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

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

            
3806
    function certStatusClass(days) {
3807
      if (days === null) return '';
3808
      if (days < 0) return 'bad';
3809
      if (days <= 30) return 'warn';
3810
      return 'ok';
3811
    }
3812

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Xdev Host Manager authored a week ago
4038
    function renderHosts() {
4039
      const filter = $('filter').value.toLowerCase();
4040
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
4041
        .slice()
Bogdan Timofte authored 4 days ago
4042
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
4043
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
4044
        .map(h => {
4045
          const problems = state.problems.filter(p => p.host_id === h.id);
4046
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 4 days ago
4047
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 3 days ago
4048
            <td><span class="pill canonical" title="${escapeHtml(h.fqdn || '')}">${escapeHtml(h.fqdn || '')}</span></td>
Bogdan Timofte authored 4 days ago
4049
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
4050
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
4051
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored 4 days ago
4052
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
4053
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4054
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 3 days ago
4055
            <td><div class="host-actions">
4056
              <button type="button" class="iconbtn" data-edit="${escapeHtml(h.id)}" title="Edit ${escapeHtml(h.fqdn || h.id || '')}" aria-label="Edit ${escapeHtml(h.fqdn || h.id || '')}">
4057
                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>
4058
              </button>
4059
              <button type="button" class="iconbtn danger" data-host-delete="${escapeHtml(h.id)}" title="Delete ${escapeHtml(h.fqdn || h.id || '')}" aria-label="Delete ${escapeHtml(h.fqdn || h.id || '')}">
4060
                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
4061
              </button>
4062
            </div></td>
Xdev Host Manager authored a week ago
4063
          </tr>`;
4064
        }).join('');
Bogdan Timofte authored 4 days ago
4065
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
4066
        editHost(button.dataset.edit).catch(e => {
4067
          if (!isAuthLost(e)) msg(e.message);
4068
        });
4069
      }));
Bogdan Timofte authored 3 days ago
4070
      document.querySelectorAll('[data-host-delete]').forEach(button => button.addEventListener('click', () => {
4071
        deleteHostInline(button.dataset.hostDelete || '').catch(e => {
Bogdan Timofte authored 4 days ago
4072
          if (!isAuthLost(e)) msg(e.message);
4073
        });
4074
      }));
4075
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4076
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4077
          if (!isAuthLost(e)) msg(e.message);
4078
        });
4079
      }));
4080
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4081
        select.addEventListener('change', () => {
4082
          setHostCertificateFromSelect(select).catch(e => {
4083
            if (!isAuthLost(e)) msg(e.message);
4084
            select.value = select.dataset.currentCertificate || '';
4085
          });
4086
        });
4087
      });
4088
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4089
        button.addEventListener('click', () => {
4090
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4091
            if (!isAuthLost(e)) msg(e.message);
4092
          });
4093
        });
4094
      });
Bogdan Timofte authored 4 days ago
4095
      mountHostEditor();
Xdev Host Manager authored a week ago
4096
    }
4097

            
Bogdan Timofte authored 4 days ago
4098
    function renderHostAliasCell(host) {
4099
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4100
        <span class="host-alias-label">${escapeHtml(name)}</span>
4101
        <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>
4102
      </span>`).join('');
4103
      return `<div class="host-alias-cell">
Bogdan Timofte authored 3 days ago
4104
        <div class="host-alias-list">${aliases}</div>
Bogdan Timofte authored 4 days ago
4105
      </div>`;
4106
    }
4107

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

            
4128
    function hostCertificateRow(host) {
4129
      return {
4130
        host_fqdn: host.fqdn || '',
4131
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4132
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4133
        certificate_id: host.certificate_id || '',
4134
        certificate: host.certificate || null,
4135
      };
Bogdan Timofte authored 4 days ago
4136
    }
4137

            
4138
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4139
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4140
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4141
        vhost,
4142
        host_id: host.id || '',
4143
        host_fqdn: host.fqdn || '',
4144
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4145
        monitoring: host.monitoring || '',
4146
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4147
        certificate_id: '',
4148
        certificate: null,
Bogdan Timofte authored 4 days ago
4149
      })));
4150
    }
4151

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

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

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

            
4237
    function renderVhostEditor() {
4238
      const select = $('vhost-new-host');
4239
      const current = select.value || '';
4240
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4241
    }
4242

            
4243
    function renderVhostHostOptions(selectedHostFqdn) {
4244
      return state.hosts
4245
        .slice()
4246
        .filter(host => (host.status || '') !== 'retired')
4247
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4248
        .map(host => {
4249
          const fqdn = host.fqdn || '';
4250
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4251
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4252
        }).join('');
Bogdan Timofte authored 4 days ago
4253
    }
4254

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

            
Bogdan Timofte authored 3 days ago
4282
    function certificateIdOf(cert) {
Bogdan Timofte authored 4 days ago
4283
      return cert ? (cert.id || cert.name || '') : '';
4284
    }
4285

            
4286
    function certDnsNames(cert) {
4287
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4288
        .map(name => String(name || '').toLowerCase())
4289
        .filter(Boolean);
4290
    }
4291

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

            
4316
    function certMatchesRow(cert, row) {
4317
      return certRelevance(cert, row) < 9;
4318
    }
4319

            
4320
    function compactCertificateLabel(cert, row) {
4321
      const relevance = certRelevance(cert, row);
4322
      const days = daysUntil(cert.not_after);
4323
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 3 days ago
4324
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 4 days ago
4325
      if (row && row.vhost) {
Bogdan Timofte authored 3 days ago
4326
        if (relevance === 0) return `${name}${suffix}`;
4327
        if (relevance === 1) return `host ${name}${suffix}`;
4328
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4329
      } else {
Bogdan Timofte authored 3 days ago
4330
        if (relevance === 0) return `${name}${suffix}`;
4331
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4332
      }
Bogdan Timofte authored 4 days ago
4333
      return `${shortCertificateName(cert)}${suffix}`;
4334
    }
4335

            
Bogdan Timofte authored 3 days ago
4336
    function certificateDisplayName(cert) {
4337
      const commonName = String(cert.common_name || '').trim();
4338
      if (commonName) return commonName;
4339
      const dnsNames = certDnsNames(cert);
4340
      if (dnsNames.length) return dnsNames[0];
4341
      return shortCertificateName(cert);
4342
    }
4343

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

            
Bogdan Timofte authored 4 days ago
4350
    function shortAliasForFqdn(name) {
4351
      const suffix = '.madagascar.xdev.ro';
4352
      name = String(name || '').toLowerCase();
4353
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
4354
    }
4355

            
Bogdan Timofte authored 4 days ago
4356
    function hostByFqdn(fqdn) {
4357
      fqdn = String(fqdn || '').toLowerCase();
4358
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4359
    }
4360

            
4361
    function hostUpsertPayload(host, overrides = {}) {
4362
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4363
      const payload = {
4364
        id: host.id || '',
4365
        fqdn: host.fqdn || '',
4366
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4367
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4368
        aliases,
4369
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
Bogdan Timofte authored 3 days ago
4370
        sources: [],
Bogdan Timofte authored 4 days ago
4371
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4372
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4373
      };
4374
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4375
      return payload;
4376
    }
4377

            
Bogdan Timofte authored 3 days ago
4378
    function aliasEditorValues() {
4379
      return (hostField('aliases').value || '')
4380
        .split(/[\s,]+/)
4381
        .map(value => String(value || '').trim().toLowerCase())
4382
        .filter(Boolean);
4383
    }
4384

            
4385
    function appendAliasInEditor() {
4386
      const fqdn = String(hostField('fqdn').value || '').trim().toLowerCase();
4387
      const derived = shortAliasForFqdn(fqdn);
4388
      const alias = String(prompt(fqdn ? `Alias nou pentru ${fqdn}` : 'Alias nou', '') || '').trim().toLowerCase();
4389
      if (!alias) return;
4390
      if (fqdn && alias === fqdn) {
4391
        msg('fqdn-ul hostului este deja numele principal');
4392
        return;
4393
      }
4394
      if (derived && alias === derived) {
4395
        msg('aliasul derivat din fqdn se genereaza automat');
4396
        return;
4397
      }
4398
      const aliases = aliasEditorValues();
4399
      if (aliases.includes(alias)) {
4400
        msg(`aliasul ${alias} este deja in editor`);
4401
        return;
4402
      }
4403
      hostField('aliases').value = aliases.concat(alias).join('\n');
4404
      hostField('aliases').dispatchEvent(new Event('input', { bubbles: true }));
4405
      hostField('aliases').focus();
4406
    }
4407

            
Bogdan Timofte authored 4 days ago
4408
    async function addHostAlias(hostFqdn) {
4409
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4410
      const host = hostByFqdn(hostFqdn);
4411
      if (!host) return;
4412
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4413
      if (!alias) return;
4414
      if (alias === String(host.fqdn || '').toLowerCase()) {
4415
        msg('fqdn-ul hostului este deja prezent');
4416
        return;
4417
      }
4418
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4419
      await api('/api/hosts/upsert', {
4420
        method: 'POST',
4421
        headers: { 'Content-Type': 'application/json' },
4422
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4423
      });
4424
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4425
      await refresh();
4426
    }
4427

            
4428
    async function removeHostAlias(hostFqdn, alias) {
4429
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4430
      const host = hostByFqdn(hostFqdn);
4431
      alias = String(alias || '').trim().toLowerCase();
4432
      if (!host || !alias) return;
4433
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4434
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4435
      await api('/api/hosts/upsert', {
4436
        method: 'POST',
4437
        headers: { 'Content-Type': 'application/json' },
4438
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4439
      });
4440
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4441
      await refresh();
4442
    }
4443

            
Bogdan Timofte authored 3 days ago
4444
    async function deleteHostInline(id) {
4445
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4446
      const host = state.hosts.find(entry => String(entry.id || '') === String(id || ''));
4447
      if (!host) return;
4448
      if (!confirm(`Delete ${host.fqdn || host.id || id}?`)) return;
4449
      await api('/api/hosts/delete', {
4450
        method: 'POST',
4451
        headers: { 'Content-Type': 'application/json' },
4452
        body: JSON.stringify({ id: host.id || id }),
4453
      });
4454
      if (hostEditorTarget === String(host.id || '')) closeHostForm(true);
4455
      msg(`host ${host.fqdn || host.id || id} deleted`);
4456
      await refresh();
4457
    }
4458

            
Bogdan Timofte authored 4 days ago
4459
    async function setHostCertificateFromSelect(select) {
4460
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4461
        select.value = select.dataset.currentCertificate || '';
4462
        return;
4463
      }
4464
      const hostFqdn = select.dataset.hostCertSelect || '';
4465
      const certificateId = select.value || '';
4466
      const current = select.dataset.currentCertificate || '';
4467
      if (!hostFqdn || certificateId === current) return;
4468
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4469
        select.value = current;
4470
        return;
4471
      }
4472
      select.disabled = true;
4473
      try {
4474
        await api('/api/hosts/certificate', {
4475
          method: 'POST',
4476
          headers: { 'Content-Type': 'application/json' },
4477
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4478
        });
4479
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4480
        await refresh();
4481
      } finally {
4482
        select.disabled = false;
4483
      }
4484
    }
4485

            
4486
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4487
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4488
      if (!hostFqdn) return;
4489
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4490
      if (button) button.disabled = true;
4491
      try {
4492
        const result = await api('/api/hosts/issue-certificate', {
4493
          method: 'POST',
4494
          headers: { 'Content-Type': 'application/json' },
4495
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4496
        });
4497
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4498
        await refresh();
4499
      } finally {
4500
        if (button) button.disabled = false;
4501
      }
4502
    }
4503

            
Bogdan Timofte authored 4 days ago
4504
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4505
      const vhost = select.dataset.vhostSelect || '';
4506
      const fromHost = select.dataset.currentHost || '';
4507
      const toHost = select.value || '';
4508
      if (!vhost || !toHost || toHost === fromHost) return;
4509
      select.disabled = true;
4510
      try {
4511
        await api('/api/vhosts/reassign', {
4512
          method: 'POST',
4513
          headers: { 'Content-Type': 'application/json' },
4514
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4515
        });
4516
        msg(`vhost ${vhost} moved`);
4517
        await refresh();
4518
      } finally {
4519
        select.disabled = false;
4520
      }
4521
    }
4522

            
Bogdan Timofte authored 4 days ago
4523
    async function addVhostInline() {
4524
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4525
      const nameInput = $('vhost-new-name');
4526
      const hostSelect = $('vhost-new-host');
4527
      const vhost = (nameInput.value || '').trim().toLowerCase();
4528
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 3 days ago
4529
      if (!vhost || !hostFqdn) {
4530
        msg('completeaza vhost si host');
4531
        return;
4532
      }
4533
      if (!isValidVhostName(vhost)) {
4534
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4535
        nameInput.focus();
4536
        return;
4537
      }
4538
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4539
        msg('vhost invalid: numele este deja host real');
4540
        nameInput.focus();
4541
        return;
4542
      }
Bogdan Timofte authored 4 days ago
4543
      $('vhost-add').disabled = true;
4544
      nameInput.disabled = true;
4545
      hostSelect.disabled = true;
4546
      try {
4547
        await api('/api/vhosts/upsert', {
4548
          method: 'POST',
4549
          headers: { 'Content-Type': 'application/json' },
4550
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4551
        });
4552
        nameInput.value = '';
4553
        msg(`vhost ${vhost} saved`);
4554
        await refresh();
Bogdan Timofte authored 3 days ago
4555
      } catch (e) {
4556
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4557
      } finally {
4558
        $('vhost-add').disabled = false;
4559
        nameInput.disabled = false;
4560
        hostSelect.disabled = false;
4561
      }
4562
    }
4563

            
Bogdan Timofte authored 3 days ago
4564
    function isValidVhostName(name) {
4565
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4566
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4567
      if (name.length > 253) return false;
4568
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4569
    }
4570

            
4571
    function vhostErrorMessage(error) {
4572
      const code = error && error.code ? error.code : '';
4573
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4574
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4575
      if (code === 'invalid_target_host') return 'host tinta invalid';
4576
      if (code === 'missing_target_host') return 'alege hostul tinta';
4577
      return error && error.message ? error.message : 'vhost add failed';
4578
    }
4579

            
Bogdan Timofte authored 4 days ago
4580
    async function setVhostCertificateFromSelect(select) {
4581
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4582
        select.value = select.dataset.currentCertificate || '';
4583
        return;
4584
      }
4585
      const vhost = select.dataset.vhostCertSelect || '';
4586
      const certificateId = select.value || '';
4587
      const current = select.dataset.currentCertificate || '';
4588
      if (!vhost || certificateId === current) return;
4589
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4590
        select.value = current;
4591
        return;
4592
      }
4593
      select.disabled = true;
4594
      try {
4595
        await api('/api/vhosts/certificate', {
4596
          method: 'POST',
4597
          headers: { 'Content-Type': 'application/json' },
4598
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4599
        });
4600
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4601
        await refresh();
4602
      } finally {
4603
        select.disabled = false;
4604
      }
4605
    }
4606

            
Bogdan Timofte authored 4 days ago
4607
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4608
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4609
      if (!vhost) return;
4610
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4611
      if (button) button.disabled = true;
4612
      try {
4613
        const result = await api('/api/vhosts/issue-certificate', {
4614
          method: 'POST',
4615
          headers: { 'Content-Type': 'application/json' },
4616
          body: JSON.stringify({ vhost_fqdn: vhost }),
4617
        });
4618
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4619
        await refresh();
4620
      } finally {
4621
        if (button) button.disabled = false;
4622
      }
4623
    }
4624

            
Bogdan Timofte authored 4 days ago
4625
    async function deleteVhostInline(vhost) {
4626
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4627
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4628
      await api('/api/vhosts/delete', {
4629
        method: 'POST',
4630
        headers: { 'Content-Type': 'application/json' },
4631
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4632
      });
4633
      msg(`vhost ${vhost} deleted`);
4634
      await refresh();
4635
    }
4636

            
Bogdan Timofte authored 4 days ago
4637
    async function editHost(id) {
4638
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4639
      const host = state.hosts.find(h => h.id === id);
4640
      if (!host) return;
Bogdan Timofte authored 4 days ago
4641
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4642
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4643
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4644
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4645
      hostField('roles').value = (host.roles || []).join(' ');
Bogdan Timofte authored 4 days ago
4646
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4647
    }
4648

            
Bogdan Timofte authored 4 days ago
4649
    async function newHost() {
4650
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4651
      if (!canSwitchHostEditor('__new__')) return;
4652
      resetHostForm(true);
4653
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4654
    }
4655

            
Bogdan Timofte authored 4 days ago
4656
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4657
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4658
      hostEditorTarget = target || '';
4659
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4660
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4661
      renderHosts();
4662
      hostFormSnapshot = hostFormState();
4663
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4664
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4665
    }
4666

            
Bogdan Timofte authored 4 days ago
4667
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4668
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4669
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4670
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4671
      hostField('status').value = 'active';
4672
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4673
      hostFormSnapshot = force ? '' : hostFormState();
4674
    }
4675

            
4676
    function closeHostForm(force = false) {
4677
      if (hostFormBusy && !force) return;
4678
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4679
      hostEditorTarget = '';
4680
      hostFormMode = 'new';
4681
      hostFormSnapshot = '';
4682
      clearHostFormMessage();
4683
      syncHostFormActions();
4684
      mountHostEditor();
4685
    }
4686

            
4687
    function canSwitchHostEditor(target) {
4688
      if (hostFormBusy) return false;
4689
      if (!hostEditorTarget) return true;
4690
      if (!hostFormDirty()) return true;
4691
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4692
      return confirm('Discard unsaved host changes?');
4693
    }
4694

            
4695
    function mountHostEditor() {
4696
      hostEditorRow.remove();
4697
      if (!hostEditorTarget) {
4698
        hostFormShell.hidden = true;
4699
        return;
4700
      }
Bogdan Timofte authored 3 days ago
4701
      hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
4702
      const tbody = $('hosts');
4703
      if (!tbody) return;
4704
      if (hostEditorTarget === '__new__') {
4705
        tbody.prepend(hostEditorRow);
4706
      } else {
4707
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4708
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4709
        if (targetRow) targetRow.after(hostEditorRow);
4710
        else tbody.prepend(hostEditorRow);
4711
      }
4712
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4713
    }
4714

            
4715
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4716
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4717
    }
4718

            
4719
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4720
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4721
    }
4722

            
4723
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4724
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4725
    }
4726

            
4727
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4728
      hostFormBusy = !!busy;
4729
      syncHostFormActions();
4730
    }
4731

            
4732
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4733
      saveHostButton.disabled = hostFormBusy;
4734
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4735
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
4736
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4737
    }
4738

            
4739
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4740
      hostFormMessage.textContent = text || '';
4741
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4742
    }
4743

            
4744
    function clearHostFormMessage() {
4745
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4746
    }
4747

            
4748
    function formObject(form) {
4749
      return Object.fromEntries(new FormData(form).entries());
4750
    }
4751

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

            
Bogdan Timofte authored 6 days ago
4757
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4758

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

            
4764
    if (loginAccount) {
4765
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4766
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4767
      loginAccount.addEventListener('input', () => {
4768
        const value = (loginAccount.value || '').trim();
4769
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4770
      });
4771
    }
4772

            
Xdev Host Manager authored a week ago
4773
    function setOtpDigit(idx, value) {
4774
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4775
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4776
      otpDigits[idx].classList.toggle('filled', !!digit);
4777
    }
4778

            
Bogdan Timofte authored 4 days ago
4779
    // Move focus to the next empty box: forward from idx, then wrapping to the
4780
    // start. This lets out-of-order entry continue (e.g. after the last box,
4781
    // jump back to the first still-empty box). Stays put when all boxes are full.
4782
    function advanceFocus(idx) {
4783
      for (let i = idx + 1; i < otpDigits.length; i++) {
4784
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4785
      }
4786
      for (let i = 0; i <= idx; i++) {
4787
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4788
      }
4789
    }
4790

            
Bogdan Timofte authored 4 days ago
4791
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4792
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4793
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4794
      const digits = (text || '').replace(/\D/g, '').split('');
4795
      if (!digits.length) return;
4796
      let last = startIdx;
4797
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4798
        last = startIdx + i;
4799
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4800
      }
Bogdan Timofte authored 4 days ago
4801
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4802
      advanceFocus(last);
Xdev Host Manager authored a week ago
4803
      maybeSubmitOtp();
4804
    }
4805

            
Bogdan Timofte authored 4 days ago
4806
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4807
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4808
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4809
    function maybeSubmitOtp() {
4810
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4811
    }
4812
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4813
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4814
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4815
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4816
      // an unknown operator, so Safari's autofill anchor on the username stays.
4817
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4818
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4819
    }
4820

            
Bogdan Timofte authored 4 days ago
4821
    otpDigits.forEach((input, idx) => {
4822
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4823
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4824
        // A single box may receive several digits at once (autofill / typing fast).
4825
        if (input.value.replace(/\D/g, '').length > 1) {
4826
          fillOtp(input.value, idx);
4827
          return;
4828
        }
4829
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4830
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4831
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4832
        maybeSubmitOtp();
4833
      });
Bogdan Timofte authored 4 days ago
4834

            
4835
      input.addEventListener('paste', (e) => {
4836
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4837
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4838
        const text = (e.clipboardData || window.clipboardData).getData('text');
4839
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4840
      });
Bogdan Timofte authored 4 days ago
4841

            
4842
      input.addEventListener('keydown', (e) => {
4843
        if (e.key === 'Backspace') {
4844
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4845
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4846
          if (input.value) { setOtpDigit(idx, ''); }
4847
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4848
          syncOtpFields();
4849
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4850
          e.preventDefault();
4851
          otpDigits[idx - 1].focus();
4852
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4853
          e.preventDefault();
4854
          otpDigits[idx + 1].focus();
4855
        }
4856
      });
4857
    });
4858

            
Bogdan Timofte authored 4 days ago
4859
    // Focus the first OTP box only for a returning operator (username known).
4860
    // For an unknown operator, leave focus on the username field so Safari can
4861
    // present its OTP autofill anchored there without being dismissed by a focus
4862
    // change (pbx-admin pattern).
4863
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4864
    else if (loginAccount) loginAccount.focus();
4865
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4866

            
Bogdan Timofte authored 5 days ago
4867
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4868
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4869
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4870
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4871
        showPage(link.dataset.pageLink, true);
4872
      });
4873
    });
4874

            
Bogdan Timofte authored 4 days ago
4875
    window.addEventListener('popstate', () => {
4876
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4877
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4878
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4879
    });
Bogdan Timofte authored 5 days ago
4880

            
Bogdan Timofte authored 4 days ago
4881
    async function copyText(text) {
4882
      if (navigator.clipboard && window.isSecureContext) {
4883
        await navigator.clipboard.writeText(text);
4884
        return;
4885
      }
4886
      const input = document.createElement('textarea');
4887
      input.value = text;
4888
      input.setAttribute('readonly', '');
4889
      input.style.position = 'fixed';
4890
      input.style.left = '-10000px';
4891
      document.body.appendChild(input);
4892
      input.select();
4893
      document.execCommand('copy');
4894
      document.body.removeChild(input);
4895
    }
4896

            
4897
    $('copy-build').addEventListener('click', async () => {
4898
      try {
4899
        await copyText($('copy-build').dataset.buildDetails || '');
4900
        if (state.authenticated) msg('build details copied');
4901
      } catch (e) {
4902
        if (state.authenticated) msg('copy failed');
4903
      }
4904
    });
4905

            
Xdev Host Manager authored a week ago
4906
    $('login-form').addEventListener('submit', async (event) => {
4907
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4908
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4909
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4910
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4911
      try {
Xdev Host Manager authored a week ago
4912
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4913
        await refresh();
Xdev Host Manager authored a week ago
4914
      } catch (e) {
4915
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4916
      } finally {
Xdev Host Manager authored a week ago
4917
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4918
      }
Xdev Host Manager authored a week ago
4919
    });
4920

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

            
Bogdan Timofte authored 4 days ago
4926
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4927
      if (!isAuthLost(e)) msg(e.message);
4928
    }));
Xdev Host Manager authored a week ago
4929
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4930
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4931
    $('vhost-add').addEventListener('click', () => {
4932
      addVhostInline().catch(e => {
4933
        if (!isAuthLost(e)) msg(e.message);
4934
      });
4935
    });
4936
    $('vhost-new-name').addEventListener('keydown', (event) => {
4937
      if (event.key !== 'Enter') return;
4938
      event.preventDefault();
4939
      addVhostInline().catch(e => {
4940
        if (!isAuthLost(e)) msg(e.message);
4941
      });
4942
    });
Bogdan Timofte authored 4 days ago
4943
    $('new-host').addEventListener('click', () => {
4944
      newHost().catch(e => {
4945
        if (!isAuthLost(e)) msg(e.message);
4946
      });
4947
    });
Bogdan Timofte authored 4 days ago
4948
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4949
      if (!isAuthLost(e)) msg(e.message);
4950
    }));
Bogdan Timofte authored 3 days ago
4951
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 4 days ago
4952
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4953

            
Bogdan Timofte authored 4 days ago
4954
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4955
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4956
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4957
      setHostFormBusy(true);
4958
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4959
      try {
4960
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4961
        msg('host saved');
4962
        await refresh();
Bogdan Timofte authored 3 days ago
4963
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4964
      } catch (e) {
Bogdan Timofte authored 4 days ago
4965
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4966
        setHostFormMessage(e.message, true);
4967
        msg(e.message);
4968
      } finally {
4969
        setHostFormBusy(false);
4970
      }
4971
    });
4972

            
Bogdan Timofte authored 4 days ago
4973
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4974
      setHostFormMessage('Complete the required host fields before saving.', true);
4975
    }, true);
4976

            
Bogdan Timofte authored 4 days ago
4977
    hostForm.addEventListener('input', () => {
4978
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4979
    });
4980

            
Bogdan Timofte authored 4 days ago
4981
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4982
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4983
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4984
      setHostFormBusy(true);
4985
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4986
      try {
4987
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4988
        msg('host deleted');
4989
        await refresh();
Bogdan Timofte authored 4 days ago
4990
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4991
      } catch (e) {
Bogdan Timofte authored 4 days ago
4992
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4993
        setHostFormMessage(e.message, true);
4994
        msg(e.message);
4995
      } finally {
4996
        setHostFormBusy(false);
4997
      }
Xdev Host Manager authored a week ago
4998
    });
4999

            
Bogdan Timofte authored 4 days ago
5000
    resetHostForm(true);
5001
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
5002

            
Xdev Host Manager authored a week ago
5003
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
5004
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
5005
      try {
5006
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
5007
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
5008
      } catch (e) {
5009
        if (!isAuthLost(e)) msg(e.message);
5010
      }
Xdev Host Manager authored a week ago
5011
    });
5012

            
Bogdan Timofte authored 4 days ago
5013
    refresh().catch(e => {
5014
      if (!isAuthLost(e)) showLogin(e.message);
5015
    });
Xdev Host Manager authored a week ago
5016
  </script>
5017
</body>
5018
</html>
5019
HTML
Bogdan Timofte authored 6 days ago
5020
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
5021
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
5022
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
5023
    return $html;
Xdev Host Manager authored a week ago
5024
}