LocalAuthority / scripts / host_manager.pl
Newer Older
5015 lines | 199.296kb
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 4 days ago
953
sub host_dns_names {
954
    my ($host) = @_;
955
    my @names;
956
    my $fqdn = canonical_host_fqdn($host);
957
    push @names, $fqdn if length $fqdn;
958
    push @names, declared_alias_names($host), derived_alias_names($host);
959
    return unique_preserve(@names);
960
}
961

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
3534
  </div>
3535

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 3 days ago
4372
    function aliasEditorValues() {
4373
      return (hostField('aliases').value || '')
4374
        .split(/[\s,]+/)
4375
        .map(value => String(value || '').trim().toLowerCase())
4376
        .filter(Boolean);
4377
    }
4378

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

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

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

            
4438
    async function setHostCertificateFromSelect(select) {
4439
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4440
        select.value = select.dataset.currentCertificate || '';
4441
        return;
4442
      }
4443
      const hostFqdn = select.dataset.hostCertSelect || '';
4444
      const certificateId = select.value || '';
4445
      const current = select.dataset.currentCertificate || '';
4446
      if (!hostFqdn || certificateId === current) return;
4447
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4448
        select.value = current;
4449
        return;
4450
      }
4451
      select.disabled = true;
4452
      try {
4453
        await api('/api/hosts/certificate', {
4454
          method: 'POST',
4455
          headers: { 'Content-Type': 'application/json' },
4456
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4457
        });
4458
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4459
        await refresh();
4460
      } finally {
4461
        select.disabled = false;
4462
      }
4463
    }
4464

            
4465
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4466
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4467
      if (!hostFqdn) return;
4468
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4469
      if (button) button.disabled = true;
4470
      try {
4471
        const result = await api('/api/hosts/issue-certificate', {
4472
          method: 'POST',
4473
          headers: { 'Content-Type': 'application/json' },
4474
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4475
        });
4476
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4477
        await refresh();
4478
      } finally {
4479
        if (button) button.disabled = false;
4480
      }
4481
    }
4482

            
Bogdan Timofte authored 4 days ago
4483
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4484
      const vhost = select.dataset.vhostSelect || '';
4485
      const fromHost = select.dataset.currentHost || '';
4486
      const toHost = select.value || '';
4487
      if (!vhost || !toHost || toHost === fromHost) return;
4488
      select.disabled = true;
4489
      try {
4490
        await api('/api/vhosts/reassign', {
4491
          method: 'POST',
4492
          headers: { 'Content-Type': 'application/json' },
4493
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4494
        });
4495
        msg(`vhost ${vhost} moved`);
4496
        await refresh();
4497
      } finally {
4498
        select.disabled = false;
4499
      }
4500
    }
4501

            
Bogdan Timofte authored 4 days ago
4502
    async function addVhostInline() {
4503
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4504
      const nameInput = $('vhost-new-name');
4505
      const hostSelect = $('vhost-new-host');
4506
      const vhost = (nameInput.value || '').trim().toLowerCase();
4507
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 3 days ago
4508
      if (!vhost || !hostFqdn) {
4509
        msg('completeaza vhost si host');
4510
        return;
4511
      }
4512
      if (!isValidVhostName(vhost)) {
4513
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4514
        nameInput.focus();
4515
        return;
4516
      }
4517
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4518
        msg('vhost invalid: numele este deja host real');
4519
        nameInput.focus();
4520
        return;
4521
      }
Bogdan Timofte authored 4 days ago
4522
      $('vhost-add').disabled = true;
4523
      nameInput.disabled = true;
4524
      hostSelect.disabled = true;
4525
      try {
4526
        await api('/api/vhosts/upsert', {
4527
          method: 'POST',
4528
          headers: { 'Content-Type': 'application/json' },
4529
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4530
        });
4531
        nameInput.value = '';
4532
        msg(`vhost ${vhost} saved`);
4533
        await refresh();
Bogdan Timofte authored 3 days ago
4534
      } catch (e) {
4535
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4536
      } finally {
4537
        $('vhost-add').disabled = false;
4538
        nameInput.disabled = false;
4539
        hostSelect.disabled = false;
4540
      }
4541
    }
4542

            
Bogdan Timofte authored 3 days ago
4543
    function isValidVhostName(name) {
4544
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4545
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4546
      if (name.length > 253) return false;
4547
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4548
    }
4549

            
4550
    function vhostErrorMessage(error) {
4551
      const code = error && error.code ? error.code : '';
4552
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4553
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4554
      if (code === 'invalid_target_host') return 'host tinta invalid';
4555
      if (code === 'missing_target_host') return 'alege hostul tinta';
4556
      return error && error.message ? error.message : 'vhost add failed';
4557
    }
4558

            
Bogdan Timofte authored 4 days ago
4559
    async function setVhostCertificateFromSelect(select) {
4560
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4561
        select.value = select.dataset.currentCertificate || '';
4562
        return;
4563
      }
4564
      const vhost = select.dataset.vhostCertSelect || '';
4565
      const certificateId = select.value || '';
4566
      const current = select.dataset.currentCertificate || '';
4567
      if (!vhost || certificateId === current) return;
4568
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4569
        select.value = current;
4570
        return;
4571
      }
4572
      select.disabled = true;
4573
      try {
4574
        await api('/api/vhosts/certificate', {
4575
          method: 'POST',
4576
          headers: { 'Content-Type': 'application/json' },
4577
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4578
        });
4579
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4580
        await refresh();
4581
      } finally {
4582
        select.disabled = false;
4583
      }
4584
    }
4585

            
Bogdan Timofte authored 4 days ago
4586
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4587
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4588
      if (!vhost) return;
4589
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4590
      if (button) button.disabled = true;
4591
      try {
4592
        const result = await api('/api/vhosts/issue-certificate', {
4593
          method: 'POST',
4594
          headers: { 'Content-Type': 'application/json' },
4595
          body: JSON.stringify({ vhost_fqdn: vhost }),
4596
        });
4597
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4598
        await refresh();
4599
      } finally {
4600
        if (button) button.disabled = false;
4601
      }
4602
    }
4603

            
Bogdan Timofte authored 4 days ago
4604
    async function deleteVhostInline(vhost) {
4605
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4606
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4607
      await api('/api/vhosts/delete', {
4608
        method: 'POST',
4609
        headers: { 'Content-Type': 'application/json' },
4610
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4611
      });
4612
      msg(`vhost ${vhost} deleted`);
4613
      await refresh();
4614
    }
4615

            
Bogdan Timofte authored 4 days ago
4616
    async function editHost(id) {
4617
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4618
      const host = state.hosts.find(h => h.id === id);
4619
      if (!host) return;
Bogdan Timofte authored 4 days ago
4620
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4621
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4622
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4623
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4624
      hostField('roles').value = (host.roles || []).join(' ');
4625
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4626
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4627
    }
4628

            
Bogdan Timofte authored 4 days ago
4629
    async function newHost() {
4630
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4631
      if (!canSwitchHostEditor('__new__')) return;
4632
      resetHostForm(true);
4633
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4634
    }
4635

            
Bogdan Timofte authored 4 days ago
4636
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4637
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4638
      hostEditorTarget = target || '';
4639
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4640
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4641
      renderHosts();
4642
      hostFormSnapshot = hostFormState();
4643
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4644
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4645
    }
4646

            
Bogdan Timofte authored 4 days ago
4647
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4648
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4649
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4650
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4651
      hostField('status').value = 'active';
4652
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4653
      hostFormSnapshot = force ? '' : hostFormState();
4654
    }
4655

            
4656
    function closeHostForm(force = false) {
4657
      if (hostFormBusy && !force) return;
4658
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4659
      hostEditorTarget = '';
4660
      hostFormMode = 'new';
4661
      hostFormSnapshot = '';
4662
      clearHostFormMessage();
4663
      syncHostFormActions();
4664
      mountHostEditor();
4665
    }
4666

            
4667
    function canSwitchHostEditor(target) {
4668
      if (hostFormBusy) return false;
4669
      if (!hostEditorTarget) return true;
4670
      if (!hostFormDirty()) return true;
4671
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4672
      return confirm('Discard unsaved host changes?');
4673
    }
4674

            
4675
    function mountHostEditor() {
4676
      hostEditorRow.remove();
4677
      if (!hostEditorTarget) {
4678
        hostFormShell.hidden = true;
4679
        return;
4680
      }
Bogdan Timofte authored 3 days ago
4681
      hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
4682
      const tbody = $('hosts');
4683
      if (!tbody) return;
4684
      if (hostEditorTarget === '__new__') {
4685
        tbody.prepend(hostEditorRow);
4686
      } else {
4687
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4688
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4689
        if (targetRow) targetRow.after(hostEditorRow);
4690
        else tbody.prepend(hostEditorRow);
4691
      }
4692
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4693
    }
4694

            
4695
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4696
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4697
    }
4698

            
4699
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4700
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4701
    }
4702

            
4703
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4704
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4705
    }
4706

            
4707
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4708
      hostFormBusy = !!busy;
4709
      syncHostFormActions();
4710
    }
4711

            
4712
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4713
      saveHostButton.disabled = hostFormBusy;
4714
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4715
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
4716
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4717
    }
4718

            
4719
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4720
      hostFormMessage.textContent = text || '';
4721
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4722
    }
4723

            
4724
    function clearHostFormMessage() {
4725
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4726
    }
4727

            
4728
    function formObject(form) {
4729
      return Object.fromEntries(new FormData(form).entries());
4730
    }
4731

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

            
Bogdan Timofte authored 6 days ago
4737
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4738

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

            
4744
    if (loginAccount) {
4745
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4746
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4747
      loginAccount.addEventListener('input', () => {
4748
        const value = (loginAccount.value || '').trim();
4749
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4750
      });
4751
    }
4752

            
Xdev Host Manager authored a week ago
4753
    function setOtpDigit(idx, value) {
4754
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4755
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4756
      otpDigits[idx].classList.toggle('filled', !!digit);
4757
    }
4758

            
Bogdan Timofte authored 4 days ago
4759
    // Move focus to the next empty box: forward from idx, then wrapping to the
4760
    // start. This lets out-of-order entry continue (e.g. after the last box,
4761
    // jump back to the first still-empty box). Stays put when all boxes are full.
4762
    function advanceFocus(idx) {
4763
      for (let i = idx + 1; i < otpDigits.length; i++) {
4764
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4765
      }
4766
      for (let i = 0; i <= idx; i++) {
4767
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4768
      }
4769
    }
4770

            
Bogdan Timofte authored 4 days ago
4771
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4772
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4773
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4774
      const digits = (text || '').replace(/\D/g, '').split('');
4775
      if (!digits.length) return;
4776
      let last = startIdx;
4777
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4778
        last = startIdx + i;
4779
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4780
      }
Bogdan Timofte authored 4 days ago
4781
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4782
      advanceFocus(last);
Xdev Host Manager authored a week ago
4783
      maybeSubmitOtp();
4784
    }
4785

            
Bogdan Timofte authored 4 days ago
4786
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4787
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4788
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4789
    function maybeSubmitOtp() {
4790
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4791
    }
4792
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4793
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4794
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4795
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4796
      // an unknown operator, so Safari's autofill anchor on the username stays.
4797
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4798
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4799
    }
4800

            
Bogdan Timofte authored 4 days ago
4801
    otpDigits.forEach((input, idx) => {
4802
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4803
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4804
        // A single box may receive several digits at once (autofill / typing fast).
4805
        if (input.value.replace(/\D/g, '').length > 1) {
4806
          fillOtp(input.value, idx);
4807
          return;
4808
        }
4809
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4810
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4811
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4812
        maybeSubmitOtp();
4813
      });
Bogdan Timofte authored 4 days ago
4814

            
4815
      input.addEventListener('paste', (e) => {
4816
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4817
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4818
        const text = (e.clipboardData || window.clipboardData).getData('text');
4819
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4820
      });
Bogdan Timofte authored 4 days ago
4821

            
4822
      input.addEventListener('keydown', (e) => {
4823
        if (e.key === 'Backspace') {
4824
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4825
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4826
          if (input.value) { setOtpDigit(idx, ''); }
4827
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4828
          syncOtpFields();
4829
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4830
          e.preventDefault();
4831
          otpDigits[idx - 1].focus();
4832
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4833
          e.preventDefault();
4834
          otpDigits[idx + 1].focus();
4835
        }
4836
      });
4837
    });
4838

            
Bogdan Timofte authored 4 days ago
4839
    // Focus the first OTP box only for a returning operator (username known).
4840
    // For an unknown operator, leave focus on the username field so Safari can
4841
    // present its OTP autofill anchored there without being dismissed by a focus
4842
    // change (pbx-admin pattern).
4843
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4844
    else if (loginAccount) loginAccount.focus();
4845
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4846

            
Bogdan Timofte authored 5 days ago
4847
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4848
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4849
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4850
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4851
        showPage(link.dataset.pageLink, true);
4852
      });
4853
    });
4854

            
Bogdan Timofte authored 4 days ago
4855
    window.addEventListener('popstate', () => {
4856
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4857
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4858
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4859
    });
Bogdan Timofte authored 5 days ago
4860

            
Bogdan Timofte authored 4 days ago
4861
    async function copyText(text) {
4862
      if (navigator.clipboard && window.isSecureContext) {
4863
        await navigator.clipboard.writeText(text);
4864
        return;
4865
      }
4866
      const input = document.createElement('textarea');
4867
      input.value = text;
4868
      input.setAttribute('readonly', '');
4869
      input.style.position = 'fixed';
4870
      input.style.left = '-10000px';
4871
      document.body.appendChild(input);
4872
      input.select();
4873
      document.execCommand('copy');
4874
      document.body.removeChild(input);
4875
    }
4876

            
4877
    $('copy-build').addEventListener('click', async () => {
4878
      try {
4879
        await copyText($('copy-build').dataset.buildDetails || '');
4880
        if (state.authenticated) msg('build details copied');
4881
      } catch (e) {
4882
        if (state.authenticated) msg('copy failed');
4883
      }
4884
    });
4885

            
Xdev Host Manager authored a week ago
4886
    $('login-form').addEventListener('submit', async (event) => {
4887
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4888
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4889
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4890
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4891
      try {
Xdev Host Manager authored a week ago
4892
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4893
        await refresh();
Xdev Host Manager authored a week ago
4894
      } catch (e) {
4895
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4896
      } finally {
Xdev Host Manager authored a week ago
4897
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4898
      }
Xdev Host Manager authored a week ago
4899
    });
4900

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

            
Bogdan Timofte authored 4 days ago
4906
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4907
      if (!isAuthLost(e)) msg(e.message);
4908
    }));
Xdev Host Manager authored a week ago
4909
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4910
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4911
    $('vhost-add').addEventListener('click', () => {
4912
      addVhostInline().catch(e => {
4913
        if (!isAuthLost(e)) msg(e.message);
4914
      });
4915
    });
4916
    $('vhost-new-name').addEventListener('keydown', (event) => {
4917
      if (event.key !== 'Enter') return;
4918
      event.preventDefault();
4919
      addVhostInline().catch(e => {
4920
        if (!isAuthLost(e)) msg(e.message);
4921
      });
4922
    });
Bogdan Timofte authored 4 days ago
4923
    $('new-host').addEventListener('click', () => {
4924
      newHost().catch(e => {
4925
        if (!isAuthLost(e)) msg(e.message);
4926
      });
4927
    });
Bogdan Timofte authored 4 days ago
4928
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4929
      if (!isAuthLost(e)) msg(e.message);
4930
    }));
Bogdan Timofte authored 3 days ago
4931
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 4 days ago
4932
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4933

            
Bogdan Timofte authored 4 days ago
4934
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4935
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4936
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4937
      setHostFormBusy(true);
4938
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4939
      try {
Bogdan Timofte authored 4 days ago
4940
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4941
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4942
        msg('host saved');
4943
        await refresh();
Bogdan Timofte authored 4 days ago
4944
        const host = state.hosts.find(entry => entry.id === savedId);
4945
        if (host) {
4946
          clearHostFormMessage();
4947
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4948
          hostField('aliases').value = (host.aliases || []).join('\n');
4949
          hostField('roles').value = (host.roles || []).join(' ');
4950
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4951
          activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 4 days ago
4952
        } else {
Bogdan Timofte authored 4 days ago
4953
          closeHostForm(true);
Bogdan Timofte authored 4 days ago
4954
        }
Bogdan Timofte authored 5 days ago
4955
      } catch (e) {
Bogdan Timofte authored 4 days ago
4956
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4957
        setHostFormMessage(e.message, true);
4958
        msg(e.message);
4959
      } finally {
4960
        setHostFormBusy(false);
4961
      }
4962
    });
4963

            
Bogdan Timofte authored 4 days ago
4964
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4965
      setHostFormMessage('Complete the required host fields before saving.', true);
4966
    }, true);
4967

            
Bogdan Timofte authored 4 days ago
4968
    hostForm.addEventListener('input', () => {
4969
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4970
    });
4971

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

            
Bogdan Timofte authored 4 days ago
4991
    resetHostForm(true);
4992
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4993

            
Xdev Host Manager authored a week ago
4994
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4995
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4996
      try {
4997
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4998
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4999
      } catch (e) {
5000
        if (!isAuthLost(e)) msg(e.message);
5001
      }
Xdev Host Manager authored a week ago
5002
    });
5003

            
Bogdan Timofte authored 4 days ago
5004
    refresh().catch(e => {
5005
      if (!isAuthLost(e)) showLogin(e.message);
5006
    });
Xdev Host Manager authored a week ago
5007
  </script>
5008
</body>
5009
</html>
5010
HTML
Bogdan Timofte authored 6 days ago
5011
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
5012
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
5013
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
5014
    return $html;
Xdev Host Manager authored a week ago
5015
}