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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
267
    return send_json($client, 404, { error => 'not_found' });
268
}
269

            
Bogdan Timofte authored 5 days ago
270
sub app_page_path {
271
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
272
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
273
}
274

            
Xdev Host Manager authored a week ago
275
sub load_registry {
Bogdan Timofte authored 4 days ago
276
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
277
    normalize_registry_policy($registry);
278
    return $registry;
Xdev Host Manager authored a week ago
279
}
280

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

            
Xdev Host Manager authored a week ago
288
sub load_work_orders {
Bogdan Timofte authored 4 days ago
289
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
290
}
291

            
292
sub save_work_orders {
293
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
294
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
295
}
296

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

            
Bogdan Timofte authored 3 days ago
313
sub collect_dhcp_leases {
314
    my ($client, $headers, $body) = @_;
315
    my $expected = $ENV{HOST_MANAGER_DHCP_PUSH_TOKEN} || '';
316
    return send_json($client, 503, { error => 'dhcp_push_not_configured' }) unless length $expected;
317

            
318
    my $provided = dhcp_push_token_from_headers($headers);
319
    return send_json($client, 401, { error => 'invalid_dhcp_push_token' }) unless token_matches($expected, $provided);
320

            
321
    my $payload = request_payload($headers, $body);
322
    my @leases = dhcp_payload_leases($payload);
323
    return send_json($client, 400, { error => 'missing_dhcp_leases' }) unless @leases;
324

            
325
    my $dbh = dbh();
326
    my $now = iso_now();
327
    my $worker_id = clean_id($payload->{worker_id} || $payload->{source_id} || 'dhcp-router');
328
    $worker_id ||= 'dhcp-router';
329
    my @stored;
330
    with_transaction($dbh, sub {
331
        upsert_dhcp_worker($dbh, $worker_id, $now);
332
        for my $lease (@leases) {
333
            my $stored = upsert_dhcp_lease($dbh, $worker_id, $lease, $now);
334
            push @stored, $stored if $stored;
335
        }
336
        $dbh->do(
337
            'UPDATE data_workers SET last_run_at = ?, updated_at = ? WHERE worker_id = ?',
338
            undef,
339
            $now,
340
            $now,
341
            $worker_id,
342
        );
343
    });
344

            
345
    return send_json($client, 200, {
346
        ok => json_bool(1),
347
        worker_id => $worker_id,
348
        stored => scalar(@stored),
349
        leases => \@stored,
350
    });
351
}
352

            
353
sub dhcp_push_token_from_headers {
354
    my ($headers) = @_;
355
    my $token = clean_scalar($headers->{'x-dhcp-push-token'} || '');
356
    return $token if length $token;
357
    my $authorization = clean_scalar($headers->{authorization} || '');
358
    return $1 if $authorization =~ /\ABearer\s+(.+)\z/i;
359
    return '';
360
}
361

            
362
sub token_matches {
363
    my ($expected, $provided) = @_;
364
    return 0 unless length($expected || '') && length($provided || '');
365
    return 0 unless length($expected) == length($provided);
366
    my $diff = 0;
367
    for my $i (0 .. length($expected) - 1) {
368
        $diff |= ord(substr($expected, $i, 1)) ^ ord(substr($provided, $i, 1));
369
    }
370
    return $diff == 0 ? 1 : 0;
371
}
372

            
373
sub dhcp_payload_leases {
374
    my ($payload) = @_;
375
    return () unless ref($payload) eq 'HASH';
376
    if (ref($payload->{leases}) eq 'ARRAY') {
377
        return grep { ref($_) eq 'HASH' } @{ $payload->{leases} };
378
    }
379
    return ($payload);
380
}
381

            
382
sub upsert_dhcp_worker {
383
    my ($dbh, $worker_id, $now) = @_;
384
    $dbh->do(
385
        'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
386
        . "VALUES (?, 'dhcp', 'Router DHCP leases', 'active', 'push:192.168.2.1', ?, 'DHCP lease push collector source.', ?, ?) "
387
        . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, status = excluded.status, '
388
        . 'source = excluded.source, last_run_at = excluded.last_run_at, notes = excluded.notes, updated_at = excluded.updated_at',
389
        undef,
390
        $worker_id,
391
        $now,
392
        $now,
393
        $now,
394
    );
395
}
396

            
397
sub upsert_dhcp_lease {
398
    my ($dbh, $worker_id, $lease, $now) = @_;
399
    my $ip = clean_ip($lease->{ip_address} || $lease->{ip} || $lease->{address} || '');
400
    my $mac = clean_mac($lease->{mac_address} || $lease->{mac} || $lease->{active_mac} || '');
401
    return unless length $ip || length $mac;
402

            
403
    my $name = normalize_dhcp_name($lease->{observed_name} || $lease->{host_name} || $lease->{hostname} || $lease->{name} || '');
404
    my $state = clean_scalar($lease->{lease_state} || $lease->{state} || $lease->{status} || '');
405
    if (!length $state && exists $lease->{bound}) {
406
        $state = ($lease->{bound} || '') eq '1' ? 'bound' : 'unbound';
407
    }
408
    $state ||= 'observed';
409

            
410
    my $lease_key = length $mac ? "$worker_id|mac|$mac" : "$worker_id|ip|$ip";
411
    my $host_fqdn = match_dhcp_host_fqdn($dbh, $name, $ip);
412
    my $raw = json_encode($lease);
413
    $dbh->do(
414
        'INSERT INTO dhcp_leases (lease_key, worker_id, host_fqdn, observed_name, ip_address, mac_address, lease_state, first_seen, last_seen, raw) '
415
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
416
        . 'ON CONFLICT(lease_key) DO UPDATE SET host_fqdn = excluded.host_fqdn, observed_name = excluded.observed_name, '
417
        . 'ip_address = excluded.ip_address, mac_address = excluded.mac_address, lease_state = excluded.lease_state, '
418
        . 'last_seen = excluded.last_seen, raw = excluded.raw',
419
        undef,
420
        $lease_key,
421
        $worker_id,
422
        length($host_fqdn) ? $host_fqdn : undef,
423
        $name,
424
        $ip,
425
        $mac,
426
        $state,
427
        $now,
428
        $now,
429
        $raw,
430
    );
431

            
432
    return {
433
        lease_key => $lease_key,
434
        host_fqdn => $host_fqdn,
435
        observed_name => $name,
436
        ip_address => $ip,
437
        mac_address => $mac,
438
        lease_state => $state,
439
    };
440
}
441

            
442
sub match_dhcp_host_fqdn {
443
    my ($dbh, $name, $ip) = @_;
444
    my @names;
445
    $name = normalize_dns_name($name || '');
446
    if (length $name) {
447
        push @names, $name;
448
        push @names, "$name.madagascar.xdev.ro" unless $name =~ /\./;
449
    }
450
    for my $candidate (unique_preserve(@names)) {
451
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ? AND status <> ?', undef, $candidate, 'retired');
452
        return $fqdn if $fqdn;
453
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $candidate, 'active');
454
        return $fqdn if $fqdn;
455
    }
456
    if (length($ip || '')) {
457
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE (dns_ip = ? OR hosts_ip = ?) AND status <> ? ORDER BY fqdn LIMIT 1', undef, $ip, $ip, 'retired');
458
        return $fqdn if $fqdn;
459
    }
460
    return '';
461
}
462

            
Xdev Host Manager authored a week ago
463
sub confirm_work_order {
464
    my ($client, $payload) = @_;
465
    my $id = clean_scalar($payload->{id} || '');
466
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
467
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
468

            
469
    my $orders = load_work_orders();
470
    my $work_order;
471
    for my $wo (@{ $orders->{work_orders} || [] }) {
472
        if (($wo->{id} || '') eq $id) {
473
            $work_order = $wo;
474
            last;
475
        }
476
    }
477
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
478
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
479
    my $incomplete = incomplete_work_order_items($work_order);
480
    return send_json($client, 409, {
481
        error => 'work_order_incomplete',
482
        incomplete => $incomplete,
483
    }) if @$incomplete;
Xdev Host Manager authored a week ago
484

            
485
    my $registry = load_registry();
486
    my $results = apply_work_order($registry, $work_order);
487
    $work_order->{status} = 'confirmed';
488
    $work_order->{confirmed_at} = iso_now();
489
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
490

            
491
    save_registry($registry);
492
    save_work_orders($orders);
493
    backup_file($opt{local_hosts_tsv});
494
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
495

            
496
    return send_json($client, 200, {
497
        ok => json_bool(1),
498
        work_order => $work_order,
499
        results => $results,
500
        local_hosts_tsv => $opt{local_hosts_tsv},
501
    });
502
}
503

            
Xdev Host Manager authored a week ago
504
sub update_work_order_checklist {
505
    my ($client, $payload) = @_;
506
    my $id = clean_scalar($payload->{id} || '');
507
    my $item_id = clean_scalar($payload->{item_id} || '');
508
    my $status = clean_scalar($payload->{status} || '');
509
    my $notes = clean_scalar($payload->{notes} || '');
510
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
511
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
512
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
513

            
514
    my $orders = load_work_orders();
515
    my $work_order;
516
    for my $wo (@{ $orders->{work_orders} || [] }) {
517
        if (($wo->{id} || '') eq $id) {
518
            $work_order = $wo;
519
            last;
520
        }
521
    }
522
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
523
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
524

            
525
    my $item;
526
    for my $candidate (@{ $work_order->{checklist} || [] }) {
527
        if (($candidate->{id} || '') eq $item_id) {
528
            $item = $candidate;
529
            last;
530
        }
531
    }
532
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
533

            
534
    $item->{status} = $status;
535
    $item->{updated_at} = iso_now();
536
    $item->{notes} = $notes if length $notes;
537
    save_work_orders($orders);
538
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
539
}
540

            
541
sub incomplete_work_order_items {
542
    my ($work_order) = @_;
543
    my @incomplete;
544
    for my $item (@{ $work_order->{checklist} || [] }) {
545
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
546
    }
547
    return \@incomplete;
548
}
549

            
Xdev Host Manager authored a week ago
550
sub apply_work_order {
551
    my ($registry, $work_order) = @_;
552
    my @results;
553
    for my $action (@{ $work_order->{actions} || [] }) {
554
        my $type = $action->{type} || '';
555
        if ($type eq 'remove_name') {
556
            my $host_id = $action->{host_id} || '';
557
            my $name = $action->{name} || '';
558
            my $removed = 0;
559
            for my $host (@{ $registry->{hosts} || [] }) {
560
                next unless ($host->{id} || '') eq $host_id;
Bogdan Timofte authored 4 days ago
561
                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
562
                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
563
                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
564
                $host->{aliases} = \@kept_aliases;
565
                $host->{vhosts} = \@kept_vhosts;
Xdev Host Manager authored a week ago
566
                last;
567
            }
568
            push @results, {
569
                type => $type,
570
                host_id => $host_id,
571
                name => $name,
572
                removed => json_bool($removed),
573
            };
574
        } else {
575
            die "Unsupported work order action: $type\n";
576
        }
577
    }
578
    return \@results;
579
}
580

            
Xdev Host Manager authored a week ago
581
sub registry_payload {
582
    my ($registry) = @_;
583
    my $problems = analyze_hosts($registry->{hosts});
Bogdan Timofte authored 4 days ago
584
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
585
    my %host_tls = host_tls_payloads($dbh);
586
    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }) } @{ $registry->{hosts} };
Bogdan Timofte authored 4 days ago
587
    my @vhosts = vhost_payloads($dbh);
588
    my @certificates = certificate_payloads($dbh);
Bogdan Timofte authored 4 days ago
589
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
590
    return {
591
        version => $registry->{version},
592
        updated_at => $registry->{updated_at},
593
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
594
        hosts => \@hosts,
Bogdan Timofte authored 4 days ago
595
        vhosts => \@vhosts,
596
        certificates => \@certificates,
Xdev Host Manager authored a week ago
597
        problems => $problems,
598
        counts => {
599
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 4 days ago
600
            vhosts => scalar(@vhosts) || $vhost_count,
Xdev Host Manager authored a week ago
601
            problems => scalar @$problems,
602
        },
603
    };
604
}
605

            
Bogdan Timofte authored 4 days ago
606
sub host_tls_payloads {
607
    my ($dbh) = @_;
608
    my %rows;
609
    my $sth = $dbh->prepare(<<'SQL');
610
SELECT
611
    ht.host_fqdn,
612
    ht.certificate_id,
613
    c.common_name,
614
    c.not_after,
615
    c.fingerprint_sha256,
616
    c.status AS certificate_status
617
FROM host_tls ht
618
LEFT JOIN certificates c ON c.certificate_id = ht.certificate_id
619
ORDER BY ht.host_fqdn
620
SQL
621
    $sth->execute;
622
    while (my $row = $sth->fetchrow_hashref) {
623
        my $host_fqdn = clean_scalar($row->{host_fqdn} || '');
624
        next unless length $host_fqdn;
625
        my $cert_id = clean_scalar($row->{certificate_id} || '');
626
        my %payload = (
627
            certificate_id => $cert_id,
628
        );
629
        if (length $cert_id) {
630
            $payload{certificate} = {
631
                id => $cert_id,
632
                name => $cert_id,
633
                common_name => clean_scalar($row->{common_name} || ''),
634
                status => clean_scalar($row->{certificate_status} || ''),
635
                not_after => clean_scalar($row->{not_after} || ''),
636
                fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
637
                has_private_key => json_bool(ca_private_key_exists($cert_id)),
638
            };
639
        }
640
        $rows{$host_fqdn} = \%payload;
641
    }
642
    return %rows;
643
}
644

            
Bogdan Timofte authored 4 days ago
645
sub vhost_payloads {
646
    my ($dbh) = @_;
647
    my @rows;
648
    my $sth = $dbh->prepare(<<'SQL');
649
SELECT
650
    v.vhost_fqdn,
651
    v.host_fqdn,
652
    v.status AS vhost_status,
653
    v.certificate_id,
654
    h.legacy_id,
655
    h.monitoring,
656
    h.status AS host_status,
657
    c.common_name,
658
    c.not_after,
659
    c.fingerprint_sha256,
660
    c.status AS certificate_status
661
FROM vhosts v
662
JOIN hosts h ON h.fqdn = v.host_fqdn
663
LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
664
WHERE v.status = 'active'
665
ORDER BY v.vhost_fqdn
666
SQL
667
    $sth->execute;
668
    while (my $row = $sth->fetchrow_hashref) {
669
        my $cert_id = clean_scalar($row->{certificate_id} || '');
670
        my %certificate = $cert_id ? (
671
            id => $cert_id,
672
            name => $cert_id,
673
            common_name => clean_scalar($row->{common_name} || ''),
674
            status => clean_scalar($row->{certificate_status} || ''),
675
            not_after => clean_scalar($row->{not_after} || ''),
676
            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
Bogdan Timofte authored 4 days ago
677
            has_private_key => json_bool(ca_private_key_exists($cert_id)),
Bogdan Timofte authored 4 days ago
678
        ) : ();
679
        push @rows, {
680
            vhost => $row->{vhost_fqdn},
681
            vhost_fqdn => $row->{vhost_fqdn},
682
            host_id => $row->{legacy_id} || '',
683
            host_fqdn => $row->{host_fqdn},
684
            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
685
            monitoring => $row->{monitoring} || '',
686
            status => $row->{host_status} || $row->{vhost_status} || '',
687
            vhost_status => $row->{vhost_status} || '',
688
            certificate_id => $cert_id,
689
            certificate => $cert_id ? \%certificate : undef,
690
        };
691
    }
692
    return @rows;
693
}
694

            
695
sub certificate_payloads {
696
    my ($dbh) = @_;
697
    my @certificates;
698
    my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
699
    $sth->execute('retired');
700
    while (my $row = $sth->fetchrow_hashref) {
701
        my $id = clean_scalar($row->{certificate_id} || '');
702
        next unless $id;
703
        push @certificates, {
704
            id => $id,
705
            name => $id,
706
            host_fqdn => $row->{host_fqdn} || '',
707
            common_name => $row->{common_name} || '',
708
            subject => $row->{subject} || '',
709
            issuer => $row->{issuer} || '',
710
            serial => $row->{serial} || '',
711
            status => $row->{status} || '',
712
            not_before => $row->{not_before} || '',
713
            not_after => $row->{not_after} || '',
714
            fingerprint_sha256 => $row->{fingerprint_sha256} || '',
715
            dns_names => [ certificate_dns_names($dbh, $id) ],
Bogdan Timofte authored 4 days ago
716
            has_private_key => json_bool(ca_private_key_exists($id)),
Bogdan Timofte authored 4 days ago
717
        };
718
    }
719
    return @certificates;
720
}
721

            
722
sub certificate_dns_names {
723
    my ($dbh, $certificate_id) = @_;
724
    my @names;
725
    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
726
    $sth->execute($certificate_id);
727
    while (my ($name) = $sth->fetchrow_array) {
728
        push @names, $name;
729
    }
730
    return @names;
731
}
732

            
Xdev Host Manager authored a week ago
733
sub upsert_host {
734
    my ($client, $payload) = @_;
735
    my $id = clean_id($payload->{id} || '');
736
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
737

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

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

            
745
    my $registry = load_registry();
Bogdan Timofte authored 4 days ago
746
    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
747
    my @vhosts = defined $payload->{vhosts}
748
        ? clean_vhost_names($payload)
749
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
750
    my %host = (
751
        id => $id,
Bogdan Timofte authored 4 days ago
752
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
753
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
754
        ip => $ip,
755
        aliases => \@aliases,
756
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
757
        roles => [ clean_list($payload->{roles}) ],
758
        sources => [ clean_list($payload->{sources}) ],
759
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
760
        notes => clean_scalar($payload->{notes} || ''),
761
    );
762

            
Bogdan Timofte authored 4 days ago
763
    my $response = eval {
764
        my $replaced = 0;
765
        for my $i (0 .. $#{ $registry->{hosts} }) {
766
            if ($registry->{hosts}->[$i]{id} eq $id) {
767
                $registry->{hosts}->[$i] = \%host;
768
                $replaced = 1;
769
                last;
770
            }
Xdev Host Manager authored a week ago
771
        }
Bogdan Timofte authored 4 days ago
772
        push @{ $registry->{hosts} }, \%host unless $replaced;
773
        save_registry($registry);
774
        1;
775
    };
776
    if (!$response) {
777
        my $err = $@ || 'upsert_failed';
778
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
779
            if $err =~ /alias_conflict:/;
780
        die $err;
Xdev Host Manager authored a week ago
781
    }
782
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
783
}
784

            
785
sub delete_host {
786
    my ($client, $id) = @_;
787
    $id = clean_id($id);
788
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
789

            
790
    my $registry = load_registry();
791
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
792
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
793
    $registry->{hosts} = \@kept;
794
    save_registry($registry);
795
    return send_json($client, 200, { ok => json_bool(1) });
796
}
797

            
Bogdan Timofte authored 4 days ago
798
sub reassign_vhost {
799
    my ($client, $payload) = @_;
800
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
801
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
Bogdan Timofte authored 4 days ago
802
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
803
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
804

            
805
    my $dbh = dbh();
806
    my ($current_fqdn) = $dbh->selectrow_array(
807
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
808
        undef,
809
        $vhost,
810
    );
811
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
812
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
813
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
814

            
815
    my $result = eval {
816
        with_transaction($dbh, sub {
817
            my $now = iso_now();
818
            $dbh->do(
819
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
820
                undef,
821
                $target_fqdn, $now, $vhost,
822
            );
823

            
824
            my $registry = load_registry_from_db();
825
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
826
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
827

            
828
            upsert_host_to_db($dbh, $target_host) if $target_host;
829
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
830
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
831
        });
832
        1;
833
    };
834
    if (!$result) {
835
        my $err = $@ || 'vhost_reassign_failed';
836
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
837
    }
838
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
839
}
840

            
Bogdan Timofte authored 4 days ago
841
sub upsert_vhost {
842
    my ($client, $payload) = @_;
843
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
844
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
Bogdan Timofte authored 4 days ago
845
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
846
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
847

            
848
    my $dbh = dbh();
849
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
Bogdan Timofte authored 4 days ago
850
    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
851
    my ($current_fqdn) = $dbh->selectrow_array(
852
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
853
        undef,
854
        $vhost,
855
    );
856

            
857
    my $result = eval {
858
        with_transaction($dbh, sub {
859
            my $now = iso_now();
860
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
861

            
862
            my $registry = load_registry_from_db();
863
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
864
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
865

            
866
            upsert_host_to_db($dbh, $target_host) if $target_host;
867
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
868
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
869
        });
870
        1;
871
    };
872
    if (!$result) {
873
        my $err = $@ || 'vhost_upsert_failed';
874
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
875
    }
876
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
877
}
878

            
879
sub delete_vhost {
880
    my ($client, $payload) = @_;
881
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
882
    my $confirm = normalize_dns_name($payload->{confirm} || '');
Bogdan Timofte authored 4 days ago
883
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
884
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
885

            
886
    my $dbh = dbh();
887
    my ($current_fqdn) = $dbh->selectrow_array(
888
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
889
        undef,
890
        $vhost,
891
    );
892
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
893

            
894
    my $result = eval {
895
        with_transaction($dbh, sub {
896
            my $now = iso_now();
897
            $dbh->do(
898
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
899
                undef,
900
                $now, $vhost,
901
            );
902

            
903
            my $registry = load_registry_from_db();
904
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
905
            upsert_host_to_db($dbh, $current_host) if $current_host;
906
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
907
        });
908
        1;
909
    };
910
    if (!$result) {
911
        my $err = $@ || 'vhost_delete_failed';
912
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
913
    }
914
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
915
}
916

            
Bogdan Timofte authored 4 days ago
917
sub set_host_certificate {
918
    my ($client, $payload) = @_;
919
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
920
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
921
    my $certificate_id = clean_certificate_id($raw_certificate_id);
922
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
923
    return send_json($client, 400, { error => 'invalid_certificate' })
924
        if length($raw_certificate_id) && !length($certificate_id);
925

            
926
    my $dbh = dbh();
927
    return send_json($client, 404, { error => 'host_not_found' })
928
        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
929
    if (length $certificate_id) {
930
        return send_json($client, 400, { error => 'invalid_certificate' })
931
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
932
    }
933

            
934
    my $now = iso_now();
935
    with_transaction($dbh, sub {
936
        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
937
        set_schema_meta($dbh, 'registry_updated_at', $now);
938
    });
939
    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
940
}
941

            
942
sub issue_host_certificate {
943
    my ($client, $payload) = @_;
944
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
945
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
946

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

            
951
    my @dns_names = unique_preserve(grep { length $_ } (
952
        $host_fqdn,
953
        declared_alias_names($host),
954
        derived_alias_names($host),
955
    ));
956
    my $certificate_id = clean_certificate_id($host_fqdn . '-' . strftime('%Y%m%d%H%M%S', localtime));
957
    my $dbh = dbh();
958
    my $issued = eval {
959
        ca_manager_output('issue', $certificate_id, @dns_names);
960
        ca_manager_json('list-json');
961
        with_transaction($dbh, sub {
962
            my $now = iso_now();
963
            upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
964
            set_schema_meta($dbh, 'registry_updated_at', $now);
965
        });
966
        1;
967
    };
968
    if (!$issued) {
969
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
970
    }
971

            
972
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
973
    return send_json($client, 200, {
974
        ok => json_bool(1),
975
        host_fqdn => $host_fqdn,
976
        certificate_id => $certificate_id,
977
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
978
    });
979
}
980

            
Bogdan Timofte authored 4 days ago
981
sub set_vhost_certificate {
982
    my ($client, $payload) = @_;
983
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
984
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
985
    my $certificate_id = clean_certificate_id($raw_certificate_id);
Bogdan Timofte authored 4 days ago
986
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
987
    return send_json($client, 400, { error => 'invalid_certificate' })
988
        if length($raw_certificate_id) && !length($certificate_id);
989

            
990
    my $dbh = dbh();
991
    return send_json($client, 404, { error => 'vhost_not_found' })
992
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
993
    if (length $certificate_id) {
994
        return send_json($client, 400, { error => 'invalid_certificate' })
995
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
996
    }
997

            
998
    my $now = iso_now();
999
    $dbh->do(
1000
        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
1001
        undef,
1002
        length($certificate_id) ? $certificate_id : undef,
1003
        length($certificate_id) ? 'local-ca' : 'none',
1004
        $now,
1005
        $vhost,
1006
        'active',
1007
    );
1008
    set_schema_meta($dbh, 'registry_updated_at', $now);
1009
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
1010
}
1011

            
1012
sub issue_vhost_certificate {
1013
    my ($client, $payload) = @_;
1014
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
Bogdan Timofte authored 4 days ago
1015
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
1016

            
1017
    my $dbh = dbh();
1018
    my ($host_fqdn) = $dbh->selectrow_array(
1019
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
1020
        undef,
1021
        $vhost,
1022
    );
1023
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
1024

            
1025
    my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
1026
    my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
1027
    my $issued = eval {
1028
        ca_manager_output('issue', $certificate_id, @dns_names);
1029
        ca_manager_json('list-json');
1030
        with_transaction($dbh, sub {
1031
            my $now = iso_now();
1032
            $dbh->do(
1033
                "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
1034
                undef,
1035
                $certificate_id,
1036
                $now,
1037
                $vhost,
1038
            );
1039
            set_schema_meta($dbh, 'registry_updated_at', $now);
1040
        });
1041
        1;
1042
    };
1043
    if (!$issued) {
1044
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
1045
    }
1046

            
1047
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
1048
    return send_json($client, 200, {
1049
        ok => json_bool(1),
1050
        vhost_fqdn => $vhost,
1051
        host_fqdn => $host_fqdn,
1052
        certificate_id => $certificate_id,
1053
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
1054
    });
1055
}
1056

            
Xdev Host Manager authored a week ago
1057
sub analyze_hosts {
1058
    my ($hosts) = @_;
1059
    my @problems;
1060
    my (%names, %ids);
1061
    for my $host (@$hosts) {
1062
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
1063
        my $fqdn = canonical_host_fqdn($host);
1064
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
1065
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
1066
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
1067
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
1068
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
1069
            if grep { /^(is|vad|b)-/ } @declared;
1070
        for my $name (@declared) {
Xdev Host Manager authored a week ago
1071
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
1072
        }
Bogdan Timofte authored 4 days ago
1073
        my %declared = map { $_ => 1 } @declared;
1074
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
1075
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
1076
                if $declared{$derived};
1077
        }
Bogdan Timofte authored 4 days ago
1078
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
1079
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
1080
    }
1081
    return \@problems;
1082
}
1083

            
Xdev Host Manager authored a week ago
1084
sub host_payload {
Bogdan Timofte authored 4 days ago
1085
    my ($host, $tls) = @_;
Xdev Host Manager authored a week ago
1086
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
1087
    $copy{fqdn} = canonical_host_fqdn($host);
1088
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
1089
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
1090
    $copy{declared_names} = [ declared_dns_names($host) ];
1091
    $copy{aliases} = [ declared_alias_names($host) ];
1092
    $copy{derived_aliases} = [ derived_alias_names($host) ];
1093
    $copy{vhosts} = [ declared_vhost_names($host) ];
1094
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Bogdan Timofte authored 4 days ago
1095
    $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
1096
    $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
Xdev Host Manager authored a week ago
1097
    return \%copy;
1098
}
1099

            
1100
sub effective_names {
1101
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
1102
    my @names = declared_dns_names($host);
1103
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
1104
    return unique_preserve(@names);
1105
}
1106

            
Bogdan Timofte authored 4 days ago
1107
sub host_dns_names {
1108
    my ($host) = @_;
1109
    my @names;
1110
    my $fqdn = canonical_host_fqdn($host);
1111
    push @names, $fqdn if length $fqdn;
1112
    push @names, declared_alias_names($host), derived_alias_names($host);
1113
    return unique_preserve(@names);
1114
}
1115

            
1116
sub vhost_cname_records {
1117
    my ($host) = @_;
1118
    my $target = canonical_host_fqdn($host);
1119
    return () unless length $target;
1120
    my @records;
1121
    for my $vhost (declared_vhost_names($host)) {
1122
        push @records, [ $vhost, $target ];
1123
        if (my $short = short_alias_for_fqdn($vhost)) {
1124
            push @records, [ $short, $target ];
1125
        }
1126
    }
1127
    my %seen;
1128
    return grep { !$seen{$_->[0]}++ } @records;
1129
}
1130

            
Bogdan Timofte authored 4 days ago
1131
sub declared_dns_names {
1132
    my ($host) = @_;
1133
    my @names;
1134
    my $fqdn = canonical_host_fqdn($host);
1135
    push @names, $fqdn if length $fqdn;
1136
    push @names, declared_alias_names($host);
1137
    push @names, declared_vhost_names($host);
1138
    return unique_preserve(@names);
1139
}
1140

            
1141
sub declared_alias_names {
1142
    my ($host) = @_;
1143
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
1144
}
1145

            
1146
sub declared_vhost_names {
1147
    my ($host) = @_;
1148
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
1149
}
1150

            
1151
sub declared_dns_names_legacy {
1152
    my ($host) = @_;
1153
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
1154
}
1155

            
1156
sub split_legacy_names {
1157
    my ($id, $names) = @_;
1158
    my $fallback = clean_id($id || '');
1159
    my (%result) = (
1160
        fqdn => '',
1161
        aliases => [],
1162
        vhosts => [],
1163
    );
1164
    for my $name (map { normalize_dns_name($_) } @$names) {
1165
        next unless length $name;
1166
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
1167
            $result{fqdn} = $name;
1168
            next;
1169
        }
1170
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
1171
            $result{fqdn} = $name;
1172
            next;
1173
        }
1174
        if (name_is_vhost($name)) {
1175
            push @{ $result{vhosts} }, $name;
1176
        } else {
1177
            push @{ $result{aliases} }, $name;
1178
        }
1179
    }
1180
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
1181
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
1182
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
1183
    return \%result;
1184
}
1185

            
1186
sub derived_alias_names {
Xdev Host Manager authored a week ago
1187
    my ($host) = @_;
1188
    my @derived;
Bogdan Timofte authored 4 days ago
1189
    my $fqdn = canonical_host_fqdn($host);
1190
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1191
    for my $name (declared_alias_names($host)) {
1192
        push @derived, short_alias_for_fqdn($name);
1193
    }
1194
    return unique_preserve(grep { length $_ } @derived);
1195
}
1196

            
1197
sub derived_vhost_alias_names {
1198
    my ($host) = @_;
1199
    my @derived;
1200
    for my $name (declared_vhost_names($host)) {
1201
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
1202
    }
Bogdan Timofte authored 4 days ago
1203
    return unique_preserve(grep { length $_ } @derived);
1204
}
1205

            
1206
sub clean_alias_names {
1207
    my ($payload) = @_;
1208
    return clean_name_bucket($payload->{aliases})
1209
        if defined $payload->{aliases};
1210
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1211
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1212
}
1213

            
1214
sub clean_vhost_names {
1215
    my ($payload) = @_;
1216
    return clean_name_bucket($payload->{vhosts})
1217
        if defined $payload->{vhosts};
1218
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1219
    return grep { name_is_vhost($_) } @legacy;
1220
}
1221

            
1222
sub clean_name_bucket {
1223
    my ($value) = @_;
1224
    my @names = clean_list($value);
1225
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1226
}
1227

            
1228
sub remove_derived_names {
1229
    my @names = @_;
1230
    my %derived;
1231
    for my $name (@names) {
1232
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1233
        $derived{$1} = 1;
1234
    }
1235
    return grep { !$derived{$_} } @names;
1236
}
1237

            
1238
sub unique_preserve {
1239
    my @values = @_;
1240
    my %seen;
1241
    return grep { !$seen{$_}++ } @values;
1242
}
1243

            
Bogdan Timofte authored 4 days ago
1244
sub canonical_ip {
1245
    my ($host) = @_;
1246
    return '' unless $host && ref($host) eq 'HASH';
1247
    for my $key (qw(ip dns_ip hosts_ip)) {
1248
        my $value = clean_scalar($host->{$key} || '');
1249
        return $value if length $value;
1250
    }
1251
    return '';
1252
}
1253

            
Xdev Host Manager authored a week ago
1254
sub problem {
1255
    my ($host, $code, $message) = @_;
1256
    return { host_id => $host->{id}, code => $code, message => $message };
1257
}
1258

            
1259
sub render_local_hosts_tsv {
1260
    my ($registry) = @_;
1261
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
1262
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
1263
    $out .= "#\n";
1264
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
1265
    $out .= "# ip<TAB>name [aliases...]\n";
Bogdan Timofte authored 4 days ago
1266
    $out .= "# CNAME<TAB>alias<TAB>target\n";
Xdev Host Manager authored a week ago
1267
    $out .= "#\n";
1268
    $out .= "# Priority rule:\n";
1269
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
1270
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
1271
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
1272
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1273
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
1274
        my $ip = canonical_ip($host);
1275
        next unless $ip;
Bogdan Timofte authored 4 days ago
1276
        my @names = host_dns_names($host);
Xdev Host Manager authored a week ago
1277
        next unless @names;
Bogdan Timofte authored 4 days ago
1278
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Bogdan Timofte authored 4 days ago
1279
        for my $record (vhost_cname_records($host)) {
1280
            $out .= join("\t", 'CNAME', @$record) . "\n";
1281
        }
Xdev Host Manager authored a week ago
1282
    }
1283
    return $out;
1284
}
1285

            
1286
sub render_monitoring {
1287
    my ($registry) = @_;
1288
    my @hosts;
1289
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1290
        next unless ($host->{status} || 'active') eq 'active';
1291
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
1292
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
1293
        push @hosts, {
1294
            id => $host->{id},
Xdev Host Manager authored a week ago
1295
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
1296
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
1297
            aliases => \@names,
Bogdan Timofte authored 4 days ago
1298
            fqdn => canonical_host_fqdn($host),
1299
            declared_names => [ declared_dns_names($host) ],
1300
            aliases_declared => [ declared_alias_names($host) ],
1301
            aliases_derived => [ derived_alias_names($host) ],
1302
            vhosts_declared => [ declared_vhost_names($host) ],
1303
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
1304
            roles => [ @{ $host->{roles} || [] } ],
1305
            monitoring => $host->{monitoring} || 'pending',
1306
            notes => $host->{notes} || '',
1307
        };
1308
    }
1309
    return {
1310
        version => $registry->{version},
1311
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
1312
        source => $opt{db},
Xdev Host Manager authored a week ago
1313
        hosts => \@hosts,
1314
    };
1315
}
1316

            
Bogdan Timofte authored 4 days ago
1317
sub debug_database_tables_payload {
1318
    my $dbh = dbh();
1319
    my @tables;
1320
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
1321
    $sth->execute;
1322
    while (my ($name) = $sth->fetchrow_array) {
1323
        my $quoted = $dbh->quote_identifier($name);
1324
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1325
        push @tables, {
1326
            name => $name,
1327
            rows => int($count || 0),
1328
        };
1329
    }
1330
    return {
1331
        database => $opt{db},
1332
        generated_at => iso_now(),
1333
        tables => \@tables,
1334
        counts => {
1335
            tables => scalar @tables,
1336
            rows => sum(map { $_->{rows} } @tables),
1337
        },
1338
    };
1339
}
1340

            
1341
sub debug_database_table_payload {
1342
    my ($table, $limit) = @_;
1343
    my $dbh = dbh();
1344
    $table = clean_scalar($table);
1345
    return { error => 'missing_table' } unless length $table;
1346
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1347
    $limit = int($limit || 100);
1348
    $limit = 1 if $limit < 1;
1349
    $limit = 500 if $limit > 500;
1350

            
1351
    my $quoted = $dbh->quote_identifier($table);
1352
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1353
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1354
    my @index_details;
1355
    for my $index (@$indexes) {
1356
        my $index_name = $index->{name} || '';
1357
        next unless length $index_name;
1358
        my $quoted_index = $dbh->quote_identifier($index_name);
1359
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1360
        push @index_details, {
1361
            name => $index_name,
1362
            unique => int($index->{unique} || 0),
1363
            origin => $index->{origin} || '',
1364
            partial => int($index->{partial} || 0),
1365
            columns => [ map { $_->{name} || '' } @$index_columns ],
1366
        };
1367
    }
1368
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1369
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1370
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1371

            
1372
    return {
1373
        database => $opt{db},
1374
        table => $table,
1375
        generated_at => iso_now(),
1376
        limit => $limit,
1377
        row_count => int($row_count || 0),
1378
        columns => $columns,
1379
        indexes => \@index_details,
1380
        foreign_keys => $foreign_keys,
1381
        rows => $rows,
1382
    };
1383
}
1384

            
Bogdan Timofte authored 4 days ago
1385
sub debug_database_table_export_payload {
1386
    my ($table) = @_;
1387
    my $dbh = dbh();
1388
    $table = clean_scalar($table);
1389
    return { error => 'missing_table' } unless length $table;
1390
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1391

            
1392
    my $quoted = $dbh->quote_identifier($table);
1393
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1394
    my @column_names = map { $_->{name} || '' } @$columns;
1395
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1396
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1397

            
1398
    return {
1399
        database => $opt{db},
1400
        table => $table,
1401
        generated_at => iso_now(),
1402
        row_count => int($row_count || 0),
1403
        columns => \@column_names,
1404
        rows => $rows,
1405
    };
1406
}
1407

            
1408
sub render_debug_table_csv {
1409
    my ($export) = @_;
1410
    my @columns = @{ $export->{columns} || [] };
1411
    my @lines = (join(',', map { csv_cell($_) } @columns));
1412
    for my $row (@{ $export->{rows} || [] }) {
1413
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1414
    }
1415
    return join("\n", @lines) . "\n";
1416
}
1417

            
1418
sub csv_cell {
1419
    my ($value) = @_;
1420
    $value = '' unless defined $value;
1421
    $value = "$value";
1422
    $value =~ s/"/""/g;
1423
    return qq("$value") if $value =~ /[",\r\n]/;
1424
    return $value;
1425
}
1426

            
1427
sub debug_table_export_filename {
1428
    my ($table, $extension) = @_;
1429
    $table = clean_scalar($table || 'table');
1430
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1431
    $table = 'table' unless length $table;
1432
    return "debug-$table.$extension";
1433
}
1434

            
Bogdan Timofte authored 4 days ago
1435
sub debug_table_exists {
1436
    my ($dbh, $table) = @_;
1437
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1438
    my ($exists) = $dbh->selectrow_array(
1439
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1440
        undef,
1441
        $table,
1442
    );
1443
    return $exists ? 1 : 0;
1444
}
1445

            
1446
sub sum {
1447
    my $total = 0;
1448
    $total += $_ || 0 for @_;
1449
    return $total;
1450
}
1451

            
Xdev Host Manager authored a week ago
1452
sub ca_script_path {
1453
    return "$project_dir/scripts/ca_manager.sh";
1454
}
1455

            
1456
sub ca_dir {
1457
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1458
}
1459

            
1460
sub ca_cert_path {
1461
    return ca_dir() . "/certs/ca.cert.pem";
1462
}
1463

            
Bogdan Timofte authored 5 days ago
1464
sub ca_issued_cert_path {
1465
    my ($name) = @_;
1466
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1467
    return ca_dir() . "/issued/$name.cert.pem";
1468
}
1469

            
Bogdan Timofte authored 4 days ago
1470
sub ca_issued_key_path {
1471
    my ($name) = @_;
1472
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1473
    return ca_dir() . "/issued/$name.key.pem";
1474
}
1475

            
Bogdan Timofte authored 4 days ago
1476
sub ca_private_key_exists {
1477
    my ($name) = @_;
1478
    return 0 unless clean_certificate_id($name || '');
1479
    return -f ca_issued_key_path($name) ? 1 : 0;
1480
}
1481

            
Bogdan Timofte authored 4 days ago
1482
sub ca_manager_output {
1483
    my (@args) = @_;
Xdev Host Manager authored a week ago
1484
    my $script = ca_script_path();
1485
    die "CA manager script is missing\n" unless -x $script;
1486
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 4 days ago
1487
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1488
    local $/;
1489
    my $out = <$fh>;
1490
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
1491
    return $out || '';
1492
}
1493

            
1494
sub ca_manager_json {
1495
    my ($command) = @_;
1496
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1497
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1498
    sync_certificates_from_json($out) if $command eq 'list-json';
1499
    return $out;
1500
}
1501

            
1502
sub sync_certificates_from_json {
1503
    my ($json) = @_;
1504
    my $certs = eval { json_decode($json || '[]') };
1505
    return if $@ || ref($certs) ne 'ARRAY';
1506
    my $dbh = dbh();
1507
    my $now = iso_now();
1508
    with_transaction($dbh, sub {
1509
        for my $cert (@$certs) {
1510
            next unless ref($cert) eq 'HASH';
1511
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1512
            next unless $name;
1513
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1514
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1515
            my $cert_path = ca_issued_cert_path($name);
1516
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1517
            my $serial = clean_scalar($cert->{serial} || '');
1518
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1519
            $dbh->do(
1520
                '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) '
1521
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1522
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1523
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1524
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1525
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1526
                undef,
1527
                $name,
1528
                $host_fqdn || undef,
1529
                $dns_names[0] || '',
1530
                clean_scalar($cert->{subject} || ''),
1531
                clean_scalar($cert->{issuer} || ''),
1532
                length($serial) ? $serial : undef,
1533
                clean_scalar($cert->{not_before} || ''),
1534
                clean_scalar($cert->{not_after} || ''),
1535
                length($fingerprint) ? $fingerprint : undef,
1536
                $cert_path,
1537
                $csr_path,
1538
                $now,
1539
                $now,
1540
            );
1541
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1542
            for my $dns_name (@dns_names) {
1543
                next unless length $dns_name;
1544
                $dbh->do(
1545
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1546
                    undef,
1547
                    $name,
1548
                    $dns_name,
1549
                );
1550
            }
1551
        }
1552
    });
1553
}
1554

            
1555
sub infer_certificate_host_fqdn {
1556
    my ($dbh, $dns_names) = @_;
1557
    for my $name (@$dns_names) {
1558
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1559
        return $fqdn if $fqdn;
1560
    }
1561
    for my $name (@$dns_names) {
1562
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1563
        return $fqdn if $fqdn;
1564
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1565
        return $fqdn if $fqdn;
1566
    }
1567
    return '';
Xdev Host Manager authored a week ago
1568
}
1569

            
Xdev Host Manager authored a week ago
1570
sub parse_hosts_yaml {
1571
    my ($text) = @_;
1572
    my %registry = (
1573
        version => 1,
1574
        updated_at => '',
1575
        policy => {},
1576
        hosts => [],
1577
    );
1578
    my ($section, $current, $list_key);
1579
    for my $line (split /\n/, $text) {
1580
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1581
        if ($line =~ /^version:\s*(\d+)/) {
1582
            $registry{version} = int($1);
1583
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1584
            $registry{updated_at} = yaml_unquote($1);
1585
        } elsif ($line =~ /^policy:\s*$/) {
1586
            $section = 'policy';
1587
        } elsif ($line =~ /^hosts:\s*$/) {
1588
            $section = 'hosts';
1589
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1590
            $registry{policy}{$1} = yaml_unquote($2);
1591
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1592
            $current = {
1593
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1594
                fqdn => '',
Xdev Host Manager authored a week ago
1595
                status => 'active',
Bogdan Timofte authored 4 days ago
1596
                ip => '',
1597
                aliases => [],
1598
                vhosts => [],
Xdev Host Manager authored a week ago
1599
                roles => [],
1600
                sources => [],
1601
                monitoring => 'pending',
1602
                notes => '',
1603
            };
1604
            push @{ $registry{hosts} }, $current;
1605
            $list_key = undef;
1606
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1607
            $list_key = $1;
1608
            $current->{$list_key} ||= [];
1609
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1610
            push @{ $current->{$list_key} }, yaml_unquote($1);
1611
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1612
            my $key = $1;
1613
            my $value = yaml_unquote($2);
1614
            if ($key eq 'ip') {
1615
                $current->{ip} = $value;
1616
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1617
                $current->{ip} ||= $value;
1618
            } elsif ($key eq 'fqdn') {
1619
                $current->{fqdn} = normalize_dns_name($value);
1620
            } elsif ($key eq 'names') {
1621
                # ignored here; legacy list is handled after parsing
1622
            } else {
1623
                $current->{$key} = $value;
1624
            }
Xdev Host Manager authored a week ago
1625
            $list_key = undef;
1626
        }
1627
    }
Bogdan Timofte authored 4 days ago
1628
    for my $host (@{ $registry{hosts} }) {
1629
        my @legacy_names = @{ $host->{names} || [] };
1630
        if (@legacy_names) {
1631
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1632
            $host->{fqdn} ||= $legacy->{fqdn};
1633
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1634
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1635
        }
1636
        delete $host->{names};
1637
        $host->{fqdn} ||= canonical_host_fqdn($host);
1638
    }
Xdev Host Manager authored a week ago
1639
    return \%registry;
1640
}
1641

            
1642
sub render_hosts_yaml {
1643
    my ($registry) = @_;
1644
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1645
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1646
    $out .= "policy:\n";
1647
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1648
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1649
    }
1650
    $out .= "hosts:\n";
1651
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1652
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1653
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1654
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1655
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1656
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1657
            $out .= "    $key:\n";
1658
            for my $value (@{ $host->{$key} || [] }) {
1659
                $out .= "      - " . yq($value) . "\n";
1660
            }
1661
        }
1662
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1663
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1664
    }
1665
    return $out;
1666
}
1667

            
Xdev Host Manager authored a week ago
1668
sub parse_work_orders_yaml {
1669
    my ($text) = @_;
1670
    my %orders = (
1671
        version => 1,
1672
        work_orders => [],
1673
    );
Xdev Host Manager authored a week ago
1674
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1675
    for my $line (split /\n/, $text) {
1676
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1677
        if ($line =~ /^version:\s*(\d+)/) {
1678
            $orders{version} = int($1);
1679
        } elsif ($line =~ /^work_orders:\s*$/) {
1680
            $section = 'work_orders';
1681
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1682
            $current = {
1683
                id => yaml_unquote($1),
1684
                status => 'pending',
Xdev Host Manager authored a week ago
1685
                checklist => [],
Xdev Host Manager authored a week ago
1686
                actions => [],
1687
            };
1688
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1689
            $list_section = '';
Xdev Host Manager authored a week ago
1690
            $current_action = undef;
Xdev Host Manager authored a week ago
1691
            $current_item = undef;
1692
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1693
            $list_section = 'checklist';
1694
            $current->{checklist} ||= [];
1695
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1696
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1697
            push @{ $current->{checklist} }, $current_item;
1698
            $current_action = undef;
1699
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1700
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1701
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1702
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1703
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1704
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1705
            $current_action = { type => yaml_unquote($1) };
1706
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1707
            $current_item = undef;
1708
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1709
            $current_action->{$1} = yaml_unquote($2);
1710
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1711
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1712
            $list_section = '';
Xdev Host Manager authored a week ago
1713
            $current_action = undef;
Xdev Host Manager authored a week ago
1714
            $current_item = undef;
Xdev Host Manager authored a week ago
1715
        }
1716
    }
1717
    return \%orders;
1718
}
1719

            
1720
sub render_work_orders_yaml {
1721
    my ($orders) = @_;
1722
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1723
    $out .= "work_orders:\n";
1724
    for my $wo (@{ $orders->{work_orders} || [] }) {
1725
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1726
        for my $key (qw(status title reason created_at confirmed_at result)) {
1727
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1728
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1729
        }
Xdev Host Manager authored a week ago
1730
        $out .= "    checklist:\n";
1731
        for my $item (@{ $wo->{checklist} || [] }) {
1732
            $out .= "      - id: " . yq($item->{id}) . "\n";
1733
            for my $key (qw(text status owner notes updated_at)) {
1734
                next unless exists $item->{$key} && length($item->{$key} || '');
1735
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1736
            }
1737
        }
Xdev Host Manager authored a week ago
1738
        $out .= "    actions:\n";
1739
        for my $action (@{ $wo->{actions} || [] }) {
1740
            $out .= "      - type: " . yq($action->{type}) . "\n";
1741
            for my $key (qw(host_id name)) {
1742
                next unless exists $action->{$key} && length($action->{$key} || '');
1743
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1744
            }
1745
        }
1746
    }
1747
    return $out;
1748
}
1749

            
Xdev Host Manager authored a week ago
1750
sub request_payload {
1751
    my ($headers, $body) = @_;
1752
    my $type = $headers->{'content-type'} || '';
1753
    if ($type =~ m{application/json}) {
1754
        return json_decode($body || '{}');
1755
    }
1756
    return { parse_params($body || '') };
1757
}
1758

            
1759
sub json_bool {
1760
    my ($value) = @_;
1761
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1762
}
1763

            
1764
sub json_encode {
1765
    my ($value) = @_;
1766
    if (!defined $value) {
1767
        return 'null';
1768
    }
1769
    my $ref = ref($value);
1770
    if (!$ref) {
1771
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1772
        return json_string($value);
1773
    }
1774
    if ($ref eq 'HostManager::JSONBool') {
1775
        return $$value ? 'true' : 'false';
1776
    }
1777
    if ($ref eq 'ARRAY') {
1778
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1779
    }
1780
    if ($ref eq 'HASH') {
1781
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1782
    }
1783
    return json_string("$value");
1784
}
1785

            
1786
sub json_string {
1787
    my ($value) = @_;
1788
    $value = '' unless defined $value;
1789
    $value =~ s/\\/\\\\/g;
1790
    $value =~ s/"/\\"/g;
1791
    $value =~ s/\n/\\n/g;
1792
    $value =~ s/\r/\\r/g;
1793
    $value =~ s/\t/\\t/g;
1794
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1795
    return qq("$value");
1796
}
1797

            
1798
sub json_decode {
1799
    my ($text) = @_;
1800
    my $i = 0;
1801
    my $len = length($text);
1802
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1803

            
1804
    $skip_ws = sub {
1805
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1806
    };
1807

            
1808
    $parse_string = sub {
1809
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1810
        $i++;
1811
        my $out = '';
1812
        while ($i < $len) {
1813
            my $ch = substr($text, $i++, 1);
1814
            return $out if $ch eq '"';
1815
            if ($ch eq "\\") {
1816
                die "Bad JSON escape\n" if $i >= $len;
1817
                my $esc = substr($text, $i++, 1);
1818
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1819
                    $out .= $esc;
1820
                } elsif ($esc eq 'b') {
1821
                    $out .= "\b";
1822
                } elsif ($esc eq 'f') {
1823
                    $out .= "\f";
1824
                } elsif ($esc eq 'n') {
1825
                    $out .= "\n";
1826
                } elsif ($esc eq 'r') {
1827
                    $out .= "\r";
1828
                } elsif ($esc eq 't') {
1829
                    $out .= "\t";
1830
                } elsif ($esc eq 'u') {
1831
                    my $hex = substr($text, $i, 4);
1832
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1833
                    $out .= chr(hex($hex));
1834
                    $i += 4;
1835
                } else {
1836
                    die "Bad JSON escape\n";
1837
                }
1838
            } else {
1839
                $out .= $ch;
1840
            }
1841
        }
1842
        die "Unterminated JSON string\n";
1843
    };
1844

            
1845
    $parse_number = sub {
1846
        my $start = $i;
1847
        $i++ if substr($text, $i, 1) eq '-';
1848
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1849
        if ($i < $len && substr($text, $i, 1) eq '.') {
1850
            $i++;
1851
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1852
        }
1853
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1854
            $i++;
1855
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1856
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1857
        }
1858
        return 0 + substr($text, $start, $i - $start);
1859
    };
1860

            
1861
    $parse_array = sub {
1862
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1863
        $i++;
1864
        my @out;
1865
        $skip_ws->();
1866
        if ($i < $len && substr($text, $i, 1) eq ']') {
1867
            $i++;
1868
            return \@out;
1869
        }
1870
        while (1) {
1871
            push @out, $parse_value->();
1872
            $skip_ws->();
1873
            my $ch = substr($text, $i++, 1);
1874
            last if $ch eq ']';
1875
            die "Expected JSON array comma\n" unless $ch eq ',';
1876
        }
1877
        return \@out;
1878
    };
1879

            
1880
    $parse_object = sub {
1881
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1882
        $i++;
1883
        my %out;
1884
        $skip_ws->();
1885
        if ($i < $len && substr($text, $i, 1) eq '}') {
1886
            $i++;
1887
            return \%out;
1888
        }
1889
        while (1) {
1890
            $skip_ws->();
1891
            my $key = $parse_string->();
1892
            $skip_ws->();
1893
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1894
            $out{$key} = $parse_value->();
1895
            $skip_ws->();
1896
            my $ch = substr($text, $i++, 1);
1897
            last if $ch eq '}';
1898
            die "Expected JSON object comma\n" unless $ch eq ',';
1899
        }
1900
        return \%out;
1901
    };
1902

            
1903
    $parse_value = sub {
1904
        $skip_ws->();
1905
        die "Unexpected end of JSON\n" if $i >= $len;
1906
        my $ch = substr($text, $i, 1);
1907
        return $parse_string->() if $ch eq '"';
1908
        return $parse_object->() if $ch eq '{';
1909
        return $parse_array->() if $ch eq '[';
1910
        if (substr($text, $i, 4) eq 'true') {
1911
            $i += 4;
1912
            return json_bool(1);
1913
        }
1914
        if (substr($text, $i, 5) eq 'false') {
1915
            $i += 5;
1916
            return json_bool(0);
1917
        }
1918
        if (substr($text, $i, 4) eq 'null') {
1919
            $i += 4;
1920
            return undef;
1921
        }
1922
        return $parse_number->() if $ch =~ /[-0-9]/;
1923
        die "Unexpected JSON token\n";
1924
    };
1925

            
1926
    my $value = $parse_value->();
1927
    $skip_ws->();
1928
    die "Trailing JSON content\n" if $i != $len;
1929
    return $value;
1930
}
1931

            
1932
sub parse_params {
1933
    my ($text) = @_;
1934
    my %out;
1935
    for my $pair (split /&/, $text) {
1936
        next unless length $pair;
1937
        my ($k, $v) = split /=/, $pair, 2;
1938
        $out{url_decode($k)} = url_decode($v || '');
1939
    }
1940
    return %out;
1941
}
1942

            
1943
sub clean_id {
1944
    my ($value) = @_;
1945
    $value = lc clean_scalar($value);
1946
    $value =~ s/[^a-z0-9_.-]+/-/g;
1947
    $value =~ s/^-+|-+$//g;
1948
    return $value;
1949
}
1950

            
Bogdan Timofte authored 4 days ago
1951
sub clean_certificate_id {
1952
    my ($value) = @_;
1953
    $value = clean_scalar($value);
1954
    return '' unless length $value;
1955
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1956
}
1957

            
Bogdan Timofte authored 3 days ago
1958
sub clean_ip {
1959
    my ($value) = @_;
1960
    $value = clean_scalar($value);
1961
    return $value if $value =~ /\A(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\z/;
1962
    return '';
1963
}
1964

            
1965
sub clean_mac {
1966
    my ($value) = @_;
1967
    $value = lc clean_scalar($value);
1968
    $value =~ s/-/:/g;
1969
    return $value if $value =~ /\A[0-9a-f]{2}(?::[0-9a-f]{2}){5}\z/;
1970
    return '';
1971
}
1972

            
1973
sub normalize_dhcp_name {
1974
    my ($value) = @_;
1975
    $value = normalize_dns_name($value || '');
1976
    $value =~ s/[^a-z0-9_.-]+/-/g;
1977
    $value =~ s/^-+|-+$//g;
1978
    return $value;
1979
}
1980

            
Xdev Host Manager authored a week ago
1981
sub clean_scalar {
1982
    my ($value) = @_;
1983
    $value = '' unless defined $value;
1984
    $value =~ s/[\r\n\t]+/ /g;
1985
    $value =~ s/^\s+|\s+$//g;
1986
    return $value;
1987
}
1988

            
1989
sub clean_list {
1990
    my ($value) = @_;
1991
    return () unless defined $value;
1992
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1993
    my @clean;
1994
    for my $item (@items) {
1995
        $item = clean_scalar($item);
1996
        push @clean, $item if length $item;
1997
    }
1998
    return @clean;
1999
}
2000

            
2001
sub yq {
2002
    my ($value) = @_;
2003
    $value = '' unless defined $value;
2004
    $value =~ s/\\/\\\\/g;
2005
    $value =~ s/"/\\"/g;
2006
    return qq("$value");
2007
}
2008

            
2009
sub yaml_unquote {
2010
    my ($value) = @_;
2011
    $value = '' unless defined $value;
2012
    $value =~ s/^\s+|\s+$//g;
2013
    if ($value =~ /^"(.*)"$/) {
2014
        $value = $1;
2015
        $value =~ s/\\"/"/g;
2016
        $value =~ s/\\\\/\\/g;
2017
    }
2018
    return $value;
2019
}
2020

            
2021
sub verify_totp {
2022
    my ($secret, $otp) = @_;
2023
    return 0 unless $secret && $otp =~ /^\d{6}$/;
2024
    my $key = eval { base32_decode($secret) };
2025
    return 0 if $@ || !length $key;
2026
    my $counter = int(time() / 30);
2027
    for my $offset (-1, 0, 1) {
2028
        return 1 if totp_code($key, $counter + $offset) eq $otp;
2029
    }
2030
    return 0;
2031
}
2032

            
2033
sub totp_code {
2034
    my ($key, $counter) = @_;
2035
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
2036
    my $hash = hmac_sha1($msg, $key);
2037
    my $offset = ord(substr($hash, -1)) & 0x0f;
2038
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
2039
    return sprintf('%06d', $bin % 1_000_000);
2040
}
2041

            
2042
sub base32_decode {
2043
    my ($text) = @_;
2044
    $text = uc($text || '');
2045
    $text =~ s/[^A-Z2-7]//g;
2046
    my %map;
2047
    my @chars = ('A'..'Z', '2'..'7');
2048
    @map{@chars} = (0..31);
2049
    my ($bits, $value, $out) = (0, 0, '');
2050
    for my $char (split //, $text) {
2051
        die "Invalid base32\n" unless exists $map{$char};
2052
        $value = ($value << 5) | $map{$char};
2053
        $bits += 5;
2054
        while ($bits >= 8) {
2055
            $bits -= 8;
2056
            $out .= chr(($value >> $bits) & 0xff);
2057
        }
2058
    }
2059
    return $out;
2060
}
2061

            
2062
sub create_session {
2063
    my $nonce = random_hex(24);
2064
    my $expires = int(time() + 8 * 3600);
2065
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
2066
    my $token = "$nonce:$expires:$sig";
2067
    $sessions{$token} = $expires;
2068
    return $token;
2069
}
2070

            
2071
sub is_authenticated {
2072
    my ($headers) = @_;
2073
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2074
    return 0 unless $token;
2075
    my ($nonce, $expires, $sig) = split /:/, $token;
2076
    return 0 unless $nonce && $expires && $sig;
2077
    return 0 if $expires < time();
2078
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
2079
    return exists $sessions{$token};
2080
}
2081

            
2082
sub expire_session {
2083
    my ($headers) = @_;
2084
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2085
    delete $sessions{$token} if $token;
2086
}
2087

            
2088
sub cookie_value {
2089
    my ($cookie, $name) = @_;
2090
    for my $part (split /;\s*/, $cookie) {
2091
        my ($k, $v) = split /=/, $part, 2;
2092
        return $v if defined $k && $k eq $name;
2093
    }
2094
    return '';
2095
}
2096

            
2097
sub send_json {
2098
    my ($client, $status, $payload, $extra_headers) = @_;
2099
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
2100
}
2101

            
Xdev Host Manager authored a week ago
2102
sub send_json_raw {
2103
    my ($client, $status, $json_body, $extra_headers) = @_;
2104
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
2105
}
2106

            
Xdev Host Manager authored a week ago
2107
sub send_html {
2108
    my ($client, $status, $html) = @_;
2109
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
2110
}
2111

            
2112
sub send_text {
2113
    my ($client, $status, $text) = @_;
2114
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
2115
}
2116

            
2117
sub send_download {
2118
    my ($client, $status, $content, $type, $filename) = @_;
2119
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
2120
}
2121

            
2122
sub send_file {
2123
    my ($client, $path, $type, $filename) = @_;
2124
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
2125
    return send_download($client, 200, read_file($path), $type, $filename);
2126
}
2127

            
2128
sub send_response {
2129
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
2130
    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
2131
    $body = '' unless defined $body;
2132
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
2133
    print $client "Content-Type: $type\r\n";
2134
    print $client "Content-Length: " . length($body) . "\r\n";
2135
    print $client "Cache-Control: no-store\r\n";
2136
    print $client "$_\r\n" for @{ $extra_headers || [] };
2137
    print $client "Connection: close\r\n\r\n";
2138
    print $client $body;
2139
}
2140

            
2141
sub read_file {
2142
    my ($path) = @_;
2143
    open my $fh, '<', $path or die "Cannot read $path: $!";
2144
    local $/;
2145
    return <$fh>;
2146
}
2147

            
2148
sub write_file {
2149
    my ($path, $content) = @_;
2150
    open my $fh, '>', $path or die "Cannot write $path: $!";
2151
    print {$fh} $content;
2152
    close $fh or die "Cannot close $path: $!";
2153
}
2154

            
2155
sub backup_file {
2156
    my ($path) = @_;
2157
    return unless -f $path;
2158
    my $backup_dir = "$project_dir/backups/host-manager";
2159
    make_path($backup_dir) unless -d $backup_dir;
2160
    my $name = $path;
2161
    $name =~ s{.*/}{};
2162
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
2163
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
2164
}
2165

            
Bogdan Timofte authored 4 days ago
2166
my $db_handle;
Bogdan Timofte authored 4 days ago
2167
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
2168

            
2169
sub dbh {
2170
    return $db_handle if $db_handle;
2171
    ensure_parent_dir($opt{db});
2172
    $db_handle = DBI->connect(
2173
        "dbi:SQLite:dbname=$opt{db}",
2174
        '',
2175
        '',
2176
        {
2177
            RaiseError => 1,
2178
            PrintError => 0,
2179
            AutoCommit => 1,
2180
            sqlite_unicode => 1,
2181
        },
2182
    ) or die "Cannot open SQLite database $opt{db}\n";
2183
    $db_handle->do('PRAGMA journal_mode = WAL');
2184
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
2185
    create_database_schema($db_handle);
2186
    seed_database($db_handle) unless $db_seeded++;
2187
    return $db_handle;
2188
}
2189

            
2190
sub create_database_schema {
2191
    my ($dbh) = @_;
2192
    $dbh->do(<<'SQL');
2193
CREATE TABLE IF NOT EXISTS schema_meta (
2194
    key TEXT PRIMARY KEY,
2195
    value TEXT NOT NULL,
2196
    updated_at TEXT NOT NULL
2197
)
2198
SQL
2199
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
2200
CREATE TABLE IF NOT EXISTS documents (
2201
    name TEXT PRIMARY KEY,
2202
    content TEXT NOT NULL,
2203
    updated_at TEXT NOT NULL
2204
)
2205
SQL
Bogdan Timofte authored 4 days ago
2206
    $dbh->do(
2207
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2208
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2209
        undef, 'schema_version', '2', iso_now()
2210
    );
2211
    $dbh->do(<<'SQL');
2212
CREATE TABLE IF NOT EXISTS hosts (
2213
    fqdn TEXT PRIMARY KEY,
2214
    legacy_id TEXT NOT NULL UNIQUE,
2215
    status TEXT NOT NULL DEFAULT 'active',
2216
    hosts_ip TEXT NOT NULL DEFAULT '',
2217
    dns_ip TEXT NOT NULL DEFAULT '',
2218
    monitoring TEXT NOT NULL DEFAULT 'pending',
2219
    notes TEXT NOT NULL DEFAULT '',
2220
    created_at TEXT NOT NULL,
2221
    updated_at TEXT NOT NULL
2222
)
2223
SQL
2224
    $dbh->do(<<'SQL');
2225
CREATE TABLE IF NOT EXISTS host_aliases (
2226
    alias_name TEXT NOT NULL,
2227
    host_fqdn TEXT NOT NULL,
2228
    alias_kind TEXT NOT NULL DEFAULT 'declared',
2229
    status TEXT NOT NULL DEFAULT 'active',
2230
    is_dns_published INTEGER NOT NULL DEFAULT 1,
2231
    created_at TEXT NOT NULL,
2232
    retired_at TEXT,
2233
    notes TEXT NOT NULL DEFAULT '',
2234
    PRIMARY KEY (alias_name, host_fqdn),
2235
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2236
)
2237
SQL
2238
    $dbh->do(<<'SQL');
2239
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
2240
ON host_aliases(alias_name)
2241
WHERE status = 'active'
2242
SQL
2243
    $dbh->do(<<'SQL');
2244
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
2245
ON host_aliases(host_fqdn, status)
2246
SQL
2247
    $dbh->do(<<'SQL');
2248
CREATE TABLE IF NOT EXISTS host_roles (
2249
    host_fqdn TEXT NOT NULL,
2250
    role TEXT NOT NULL,
2251
    status TEXT NOT NULL DEFAULT 'active',
2252
    created_at TEXT NOT NULL,
2253
    retired_at TEXT,
2254
    PRIMARY KEY (host_fqdn, role),
2255
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2256
)
2257
SQL
2258
    $dbh->do(<<'SQL');
2259
CREATE TABLE IF NOT EXISTS host_sources (
2260
    host_fqdn TEXT NOT NULL,
2261
    source TEXT NOT NULL,
2262
    status TEXT NOT NULL DEFAULT 'active',
2263
    created_at TEXT NOT NULL,
2264
    retired_at TEXT,
2265
    PRIMARY KEY (host_fqdn, source),
2266
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2267
)
2268
SQL
2269
    $dbh->do(<<'SQL');
2270
CREATE TABLE IF NOT EXISTS host_flags (
2271
    host_fqdn TEXT NOT NULL,
2272
    flag TEXT NOT NULL,
2273
    value TEXT NOT NULL DEFAULT '1',
2274
    created_at TEXT NOT NULL,
2275
    updated_at TEXT NOT NULL,
2276
    PRIMARY KEY (host_fqdn, flag),
2277
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2278
)
2279
SQL
2280
    $dbh->do(<<'SQL');
2281
CREATE TABLE IF NOT EXISTS host_ssh (
2282
    host_fqdn TEXT NOT NULL,
2283
    profile_name TEXT NOT NULL DEFAULT 'default',
2284
    username TEXT NOT NULL DEFAULT '',
2285
    port INTEGER NOT NULL DEFAULT 22,
2286
    identity_file TEXT NOT NULL DEFAULT '',
2287
    address TEXT NOT NULL DEFAULT '',
2288
    local_forward_host TEXT NOT NULL DEFAULT '',
2289
    local_forward_port INTEGER,
2290
    remote_forward_host TEXT NOT NULL DEFAULT '',
2291
    remote_forward_port INTEGER,
2292
    notes TEXT NOT NULL DEFAULT '',
2293
    created_at TEXT NOT NULL,
2294
    updated_at TEXT NOT NULL,
2295
    PRIMARY KEY (host_fqdn, profile_name),
2296
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2297
)
Bogdan Timofte authored 4 days ago
2298
SQL
2299
    $dbh->do(<<'SQL');
2300
CREATE TABLE IF NOT EXISTS host_tls (
2301
    host_fqdn TEXT PRIMARY KEY,
2302
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2303
    certificate_id TEXT,
2304
    notes TEXT NOT NULL DEFAULT '',
2305
    created_at TEXT NOT NULL,
2306
    updated_at TEXT NOT NULL,
2307
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE CASCADE,
2308
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2309
)
2310
SQL
2311
    $dbh->do(<<'SQL');
2312
CREATE INDEX IF NOT EXISTS idx_host_tls_certificate
2313
ON host_tls(certificate_id)
Bogdan Timofte authored 4 days ago
2314
SQL
2315
    $dbh->do(<<'SQL');
2316
CREATE TABLE IF NOT EXISTS certificates (
2317
    certificate_id TEXT PRIMARY KEY,
2318
    host_fqdn TEXT,
2319
    common_name TEXT NOT NULL DEFAULT '',
2320
    subject TEXT NOT NULL DEFAULT '',
2321
    issuer TEXT NOT NULL DEFAULT '',
2322
    serial TEXT UNIQUE,
2323
    status TEXT NOT NULL DEFAULT 'issued',
2324
    not_before TEXT NOT NULL DEFAULT '',
2325
    not_after TEXT NOT NULL DEFAULT '',
2326
    fingerprint_sha256 TEXT UNIQUE,
2327
    cert_path TEXT NOT NULL DEFAULT '',
2328
    csr_path TEXT NOT NULL DEFAULT '',
2329
    created_at TEXT NOT NULL,
2330
    updated_at TEXT NOT NULL,
2331
    notes TEXT NOT NULL DEFAULT '',
2332
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2333
)
2334
SQL
2335
    $dbh->do(<<'SQL');
2336
CREATE TABLE IF NOT EXISTS certificate_dns_names (
2337
    certificate_id TEXT NOT NULL,
2338
    dns_name TEXT NOT NULL,
2339
    PRIMARY KEY (certificate_id, dns_name),
2340
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
2341
)
2342
SQL
2343
    $dbh->do(<<'SQL');
2344
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
2345
ON certificate_dns_names(dns_name)
2346
SQL
2347
    $dbh->do(<<'SQL');
2348
CREATE TABLE IF NOT EXISTS vhosts (
2349
    vhost_fqdn TEXT PRIMARY KEY,
2350
    host_fqdn TEXT NOT NULL,
2351
    status TEXT NOT NULL DEFAULT 'active',
2352
    service_name TEXT NOT NULL DEFAULT '',
2353
    upstream_url TEXT NOT NULL DEFAULT '',
2354
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2355
    certificate_id TEXT,
2356
    notes TEXT NOT NULL DEFAULT '',
2357
    created_at TEXT NOT NULL,
2358
    updated_at TEXT NOT NULL,
2359
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2360
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2361
)
2362
SQL
2363
    $dbh->do(<<'SQL');
2364
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
2365
ON vhosts(host_fqdn, status)
2366
SQL
2367
    $dbh->do(<<'SQL');
2368
CREATE TABLE IF NOT EXISTS data_workers (
2369
    worker_id TEXT PRIMARY KEY,
2370
    worker_type TEXT NOT NULL,
2371
    name TEXT NOT NULL DEFAULT '',
2372
    status TEXT NOT NULL DEFAULT 'active',
2373
    source TEXT NOT NULL DEFAULT '',
2374
    last_run_at TEXT,
2375
    notes TEXT NOT NULL DEFAULT '',
2376
    created_at TEXT NOT NULL,
2377
    updated_at TEXT NOT NULL
2378
)
2379
SQL
2380
    $dbh->do(<<'SQL');
2381
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
2382
ON data_workers(worker_type, status)
2383
SQL
2384
    $dbh->do(<<'SQL');
2385
CREATE TABLE IF NOT EXISTS dhcp_leases (
2386
    lease_key TEXT PRIMARY KEY,
2387
    worker_id TEXT NOT NULL,
2388
    host_fqdn TEXT,
2389
    observed_name TEXT NOT NULL DEFAULT '',
2390
    ip_address TEXT NOT NULL,
2391
    mac_address TEXT NOT NULL DEFAULT '',
2392
    lease_state TEXT NOT NULL DEFAULT '',
2393
    first_seen TEXT NOT NULL,
2394
    last_seen TEXT NOT NULL,
2395
    raw TEXT NOT NULL DEFAULT '',
2396
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2397
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2398
)
2399
SQL
2400
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
2401
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
2402
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
2403
    $dbh->do(<<'SQL');
2404
CREATE TABLE IF NOT EXISTS mdns_observations (
2405
    observation_key TEXT PRIMARY KEY,
2406
    worker_id TEXT NOT NULL,
2407
    host_fqdn TEXT,
2408
    observed_name TEXT NOT NULL,
2409
    ip_address TEXT NOT NULL,
2410
    rr_type TEXT NOT NULL DEFAULT 'A',
2411
    ttl INTEGER NOT NULL DEFAULT 0,
2412
    first_seen TEXT NOT NULL,
2413
    last_seen TEXT NOT NULL,
2414
    seen_count INTEGER NOT NULL DEFAULT 1,
2415
    last_peer TEXT NOT NULL DEFAULT '',
2416
    raw TEXT NOT NULL DEFAULT '',
2417
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2418
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2419
)
2420
SQL
2421
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
2422
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
2423
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
2424
    $dbh->do(<<'SQL');
2425
CREATE TABLE IF NOT EXISTS work_orders (
2426
    id TEXT PRIMARY KEY,
2427
    status TEXT NOT NULL DEFAULT 'pending',
2428
    title TEXT NOT NULL DEFAULT '',
2429
    reason TEXT NOT NULL DEFAULT '',
2430
    created_at TEXT NOT NULL,
2431
    confirmed_at TEXT NOT NULL DEFAULT '',
2432
    result TEXT NOT NULL DEFAULT '',
2433
    updated_at TEXT NOT NULL
2434
)
2435
SQL
2436
    $dbh->do(<<'SQL');
2437
CREATE TABLE IF NOT EXISTS work_order_checklist (
2438
    work_order_id TEXT NOT NULL,
2439
    item_id TEXT NOT NULL,
2440
    text TEXT NOT NULL DEFAULT '',
2441
    status TEXT NOT NULL DEFAULT 'pending',
2442
    owner TEXT NOT NULL DEFAULT '',
2443
    notes TEXT NOT NULL DEFAULT '',
2444
    updated_at TEXT NOT NULL DEFAULT '',
2445
    PRIMARY KEY (work_order_id, item_id),
2446
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
2447
)
2448
SQL
2449
    $dbh->do(<<'SQL');
2450
CREATE TABLE IF NOT EXISTS work_order_actions (
2451
    work_order_id TEXT NOT NULL,
2452
    position INTEGER NOT NULL,
2453
    type TEXT NOT NULL,
2454
    host_fqdn TEXT,
2455
    host_legacy_id TEXT NOT NULL DEFAULT '',
2456
    name TEXT NOT NULL DEFAULT '',
2457
    payload TEXT NOT NULL DEFAULT '',
2458
    PRIMARY KEY (work_order_id, position),
2459
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
2460
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2461
)
2462
SQL
Bogdan Timofte authored 4 days ago
2463
}
2464

            
Bogdan Timofte authored 4 days ago
2465
sub seed_database {
2466
    my ($dbh) = @_;
2467
    seed_default_workers($dbh);
2468

            
2469
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2470
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2471
        normalize_registry_policy($registry);
2472
        with_transaction($dbh, sub {
2473
            import_registry_to_db($dbh, $registry, 0);
2474
        });
2475
    }
2476

            
2477
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2478
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2479
        with_transaction($dbh, sub {
2480
            import_work_orders_to_db($dbh, $orders);
2481
        });
2482
    }
2483

            
2484
    seed_mdns_observations_from_yaml($dbh);
2485
}
2486

            
2487
sub with_transaction {
2488
    my ($dbh, $code) = @_;
2489
    return $code->() unless $dbh->{AutoCommit};
2490
    $dbh->begin_work;
2491
    my $ok = eval {
2492
        $code->();
2493
        1;
2494
    };
2495
    if (!$ok) {
2496
        my $err = $@ || 'transaction failed';
2497
        eval { $dbh->rollback };
2498
        die $err;
2499
    }
2500
    $dbh->commit;
2501
}
2502

            
2503
sub db_scalar {
2504
    my ($dbh, $sql, @bind) = @_;
2505
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2506
    return $value || 0;
2507
}
2508

            
2509
sub legacy_document_text {
2510
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2511
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2512
    return $row->{content} if $row && defined $row->{content};
2513
    return read_file($seed_path) if -f $seed_path;
2514
    return $default_text;
2515
}
2516

            
2517
sub load_registry_from_db {
2518
    my $dbh = dbh();
2519
    my $registry = {
2520
        version => 1,
2521
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2522
        policy => {},
2523
        hosts => [],
2524
    };
Bogdan Timofte authored 4 days ago
2525

            
Bogdan Timofte authored 4 days ago
2526
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2527
    $sth->execute;
2528
    while (my $row = $sth->fetchrow_hashref) {
2529
        my $fqdn = $row->{fqdn};
2530
        push @{ $registry->{hosts} }, {
2531
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2532
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2533
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2534
            ip => canonical_ip($row),
2535
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2536
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2537
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2538
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2539
            monitoring => $row->{monitoring},
2540
            notes => $row->{notes},
2541
        };
2542
    }
2543

            
2544
    return $registry;
Bogdan Timofte authored 4 days ago
2545
}
2546

            
Bogdan Timofte authored 4 days ago
2547
sub save_registry_to_db {
2548
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2549
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2550
    with_transaction($dbh, sub {
2551
        import_registry_to_db($dbh, $registry, 1);
2552
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2553
    });
2554
}
2555

            
2556
sub import_registry_to_db {
2557
    my ($dbh, $registry, $retire_missing) = @_;
2558
    my %seen;
2559
    for my $host (@{ $registry->{hosts} || [] }) {
2560
        my $fqdn = upsert_host_to_db($dbh, $host);
2561
        $seen{$fqdn} = 1 if $fqdn;
2562
    }
2563

            
2564
    return unless $retire_missing;
2565
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2566
    $sth->execute('retired');
2567
    while (my ($fqdn) = $sth->fetchrow_array) {
2568
        next if $seen{$fqdn};
2569
        retire_host_in_db($dbh, $fqdn);
2570
    }
2571
}
2572

            
2573
sub upsert_host_to_db {
2574
    my ($dbh, $host) = @_;
2575
    my $now = iso_now();
2576
    my $fqdn = canonical_host_fqdn($host);
2577
    return '' unless $fqdn;
2578
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2579
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2580
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2581
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2582
    my $notes = clean_scalar($host->{notes} || '');
2583

            
Bogdan Timofte authored 4 days ago
2584
    $dbh->do(
Bogdan Timofte authored 4 days ago
2585
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2586
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2587
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2588
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2589
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2590
        undef,
Bogdan Timofte authored 4 days ago
2591
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2592
    );
2593

            
2594
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2595
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2596
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2597
    return $fqdn;
2598
}
2599

            
Bogdan Timofte authored 4 days ago
2600
sub upsert_host_tls_row {
2601
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2602
    $certificate_id = clean_certificate_id($certificate_id || '');
2603
    $dbh->do(
2604
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2605
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2606
        undef,
2607
        $host_fqdn,
2608
        length($certificate_id) ? 'local-ca' : 'none',
2609
        length($certificate_id) ? $certificate_id : undef,
2610
        '',
2611
        $now,
2612
        $now,
2613
    );
2614
}
2615

            
Bogdan Timofte authored 4 days ago
2616
sub sync_host_values {
2617
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2618
    my $now = iso_now();
2619
    my %active = map { $_ => 1 } @$values;
2620
    for my $value (@$values) {
2621
        $dbh->do(
2622
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2623
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2624
            undef,
2625
            $fqdn, $value, $now,
2626
        );
2627
    }
2628

            
2629
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2630
    $sth->execute($fqdn);
2631
    while (my ($value) = $sth->fetchrow_array) {
2632
        next if $active{$value};
2633
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2634
    }
2635
}
2636

            
Bogdan Timofte authored 4 days ago
2637
sub sync_host_aliases_and_vhosts {
2638
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2639
    my $now = iso_now();
2640
    my (%aliases, %vhosts);
2641
    if (my $short = short_alias_for_fqdn($fqdn)) {
2642
        $aliases{$short} = 1;
2643
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2644
    }
Bogdan Timofte authored 4 days ago
2645
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2646
        $name = normalize_dns_name($name);
2647
        next unless length $name;
2648
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2649
        $aliases{$name} = 1;
2650
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2651
        if (my $short = short_alias_for_fqdn($name)) {
2652
            $aliases{$short} = 1;
2653
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2654
        }
2655
    }
2656
    for my $name (@$vhosts_in) {
2657
        $name = normalize_dns_name($name);
2658
        next unless length $name;
2659
        $vhosts{$name} = 1;
2660
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2661
        if (my $short = short_alias_for_fqdn($name)) {
2662
            $aliases{$short} = 1;
2663
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2664
        }
2665
    }
2666

            
2667
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2668
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2669
}
2670

            
2671
sub upsert_alias_to_db {
2672
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2673
    my ($existing_fqdn) = $dbh->selectrow_array(
2674
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2675
        undef,
2676
        $alias,
2677
    );
2678
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2679
        if ($kind eq 'derived-vhost') {
2680
            $dbh->do(
2681
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2682
                undef,
2683
                $now, $alias, $existing_fqdn,
2684
            );
2685
        } else {
2686
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2687
        }
2688
    }
Bogdan Timofte authored 4 days ago
2689
    $dbh->do(
2690
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2691
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2692
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2693
        undef,
2694
        $alias, $fqdn, $kind, $now,
2695
    );
2696
}
2697

            
2698
sub upsert_vhost_to_db {
2699
    my ($dbh, $fqdn, $vhost, $now) = @_;
2700
    my $service = vhost_service_name($vhost);
2701
    $dbh->do(
2702
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2703
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2704
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2705
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2706
        undef,
2707
        $vhost, $fqdn, $service, $now, $now,
2708
    );
2709
}
2710

            
2711
sub retire_missing_names {
2712
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2713
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2714
    $sth->execute($fqdn);
2715
    while (my ($name) = $sth->fetchrow_array) {
2716
        next if $active->{$name};
2717
        if ($table eq 'host_aliases') {
2718
            $dbh->do(
2719
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2720
                undef, $now, $fqdn, $name,
2721
            );
2722
        } else {
2723
            $dbh->do(
2724
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2725
                undef, $now, $fqdn, $name,
2726
            );
2727
        }
2728
    }
2729
}
2730

            
2731
sub retire_host_in_db {
2732
    my ($dbh, $fqdn) = @_;
2733
    my $now = iso_now();
2734
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2735
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2736
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2737
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2738
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2739
}
2740

            
Bogdan Timofte authored 4 days ago
2741
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2742
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2743
    my @names;
Bogdan Timofte authored 4 days ago
2744
    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");
2745
    $aliases->execute($fqdn);
2746
    while (my ($name) = $aliases->fetchrow_array) {
2747
        push @names, $name;
2748
    }
Bogdan Timofte authored 4 days ago
2749
    return unique_preserve(@names);
2750
}
2751

            
2752
sub active_vhosts_for_host {
2753
    my ($dbh, $fqdn) = @_;
2754
    my @names;
Bogdan Timofte authored 4 days ago
2755
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2756
    $vhosts->execute($fqdn);
2757
    while (my ($name) = $vhosts->fetchrow_array) {
2758
        push @names, $name;
2759
    }
2760
    return unique_preserve(@names);
2761
}
2762

            
2763
sub active_values_for_host {
2764
    my ($dbh, $table, $column, $fqdn) = @_;
2765
    my @values;
2766
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2767
    $sth->execute($fqdn);
2768
    while (my ($value) = $sth->fetchrow_array) {
2769
        push @values, $value;
2770
    }
2771
    return @values;
2772
}
2773

            
2774
sub load_work_orders_from_db {
2775
    my $dbh = dbh();
2776
    my $orders = { version => 1, work_orders => [] };
2777
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2778
    $sth->execute;
2779
    while (my $row = $sth->fetchrow_hashref) {
2780
        my $wo = {
2781
            id => $row->{id},
2782
            status => $row->{status},
2783
            title => $row->{title},
2784
            reason => $row->{reason},
2785
            created_at => $row->{created_at},
2786
            checklist => [],
2787
            actions => [],
2788
        };
2789
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2790
        $wo->{result} = $row->{result} if length($row->{result} || '');
2791

            
2792
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2793
        $items->execute($row->{id});
2794
        while (my $item = $items->fetchrow_hashref) {
2795
            my %copy = (
2796
                id => $item->{item_id},
2797
                text => $item->{text},
2798
                status => $item->{status},
2799
            );
2800
            for my $key (qw(owner notes updated_at)) {
2801
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2802
            }
2803
            push @{ $wo->{checklist} }, \%copy;
2804
        }
2805

            
2806
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2807
        $actions->execute($row->{id});
2808
        while (my $action = $actions->fetchrow_hashref) {
2809
            my %copy = ( type => $action->{type} );
2810
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2811
            $copy{name} = $action->{name} if length($action->{name} || '');
2812
            push @{ $wo->{actions} }, \%copy;
2813
        }
2814

            
2815
        push @{ $orders->{work_orders} }, $wo;
2816
    }
2817
    return $orders;
2818
}
2819

            
2820
sub save_work_orders_to_db {
2821
    my ($orders) = @_;
2822
    my $dbh = dbh();
2823
    with_transaction($dbh, sub {
2824
        import_work_orders_to_db($dbh, $orders);
2825
    });
2826
}
2827

            
2828
sub import_work_orders_to_db {
2829
    my ($dbh, $orders) = @_;
2830
    my $now = iso_now();
2831
    my %seen;
2832
    for my $wo (@{ $orders->{work_orders} || [] }) {
2833
        my $id = clean_scalar($wo->{id} || '');
2834
        next unless $id;
2835
        $seen{$id} = 1;
2836
        $dbh->do(
2837
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2838
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2839
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2840
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2841
            undef,
2842
            $id,
2843
            clean_scalar($wo->{status} || 'pending'),
2844
            clean_scalar($wo->{title} || ''),
2845
            clean_scalar($wo->{reason} || ''),
2846
            clean_scalar($wo->{created_at} || $now),
2847
            clean_scalar($wo->{confirmed_at} || ''),
2848
            clean_scalar($wo->{result} || ''),
2849
            $now,
2850
        );
2851
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2852
        for my $item (@{ $wo->{checklist} || [] }) {
2853
            $dbh->do(
2854
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2855
                undef,
2856
                $id,
2857
                clean_scalar($item->{id} || ''),
2858
                clean_scalar($item->{text} || ''),
2859
                clean_scalar($item->{status} || 'pending'),
2860
                clean_scalar($item->{owner} || ''),
2861
                clean_scalar($item->{notes} || ''),
2862
                clean_scalar($item->{updated_at} || ''),
2863
            );
2864
        }
2865
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2866
        my $position = 0;
2867
        for my $action (@{ $wo->{actions} || [] }) {
2868
            my $legacy_id = clean_id($action->{host_id} || '');
2869
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2870
            $dbh->do(
2871
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2872
                undef,
2873
                $id,
2874
                $position++,
2875
                clean_scalar($action->{type} || ''),
2876
                $host_fqdn || undef,
2877
                $legacy_id,
2878
                normalize_dns_name($action->{name} || ''),
2879
                '',
2880
            );
2881
        }
2882
    }
2883
}
2884

            
2885
sub seed_default_workers {
2886
    my ($dbh) = @_;
2887
    my $now = iso_now();
2888
    my @workers = (
2889
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2890
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2891
    );
2892
    for my $worker (@workers) {
2893
        $dbh->do(
2894
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2895
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2896
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2897
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2898
            undef,
2899
            @$worker,
2900
            $now,
2901
            $now,
2902
        );
2903
    }
2904
}
2905

            
2906
sub seed_mdns_observations_from_yaml {
2907
    my ($dbh) = @_;
2908
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2909
    my $path = "$project_dir/var/mdns-observations.yaml";
2910
    return unless -f $path;
2911
    my $db = parse_mdns_observations_yaml(read_file($path));
2912
    with_transaction($dbh, sub {
2913
        for my $observation (@{ $db->{observations} || [] }) {
2914
            $dbh->do(
2915
                '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) '
2916
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2917
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2918
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2919
                undef,
2920
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2921
                clean_scalar($observation->{name} || ''),
2922
                clean_scalar($observation->{ip} || ''),
2923
                int($observation->{ttl} || 0),
2924
                clean_scalar($observation->{first_seen} || iso_now()),
2925
                clean_scalar($observation->{last_seen} || iso_now()),
2926
                int($observation->{seen_count} || 1),
2927
                clean_scalar($observation->{last_peer} || ''),
2928
            );
2929
        }
2930
    });
2931
}
2932

            
2933
sub parse_mdns_observations_yaml {
2934
    my ($text) = @_;
2935
    my %db = ( observations => [] );
2936
    my ($section, $current);
2937
    for my $line (split /\n/, $text || '') {
2938
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2939
        if ($line =~ /^observations:\s*$/) {
2940
            $section = 'observations';
2941
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2942
            $current = { key => yaml_unquote($1) };
2943
            push @{ $db{observations} }, $current;
2944
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2945
            $current->{$1} = yaml_unquote($2);
2946
        }
2947
    }
2948
    return \%db;
2949
}
2950

            
2951
sub set_schema_meta {
2952
    my ($dbh, $key, $value) = @_;
2953
    $dbh->do(
2954
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2955
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2956
        undef,
2957
        $key,
2958
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2959
        iso_now(),
2960
    );
2961
}
2962

            
Bogdan Timofte authored 4 days ago
2963
sub fqdn_for_legacy_id {
2964
    my ($dbh, $legacy_id) = @_;
2965
    return '' unless length($legacy_id || '');
2966
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2967
    return $fqdn || '';
2968
}
2969

            
2970
sub canonical_host_fqdn {
2971
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2972
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2973
    return $fqdn if length $fqdn;
2974
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2975
    for my $name (@names) {
2976
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2977
    }
2978
    for my $name (@names) {
2979
        return $name if $name =~ /\./ && !name_is_vhost($name);
2980
    }
2981
    my $id = clean_id($host->{id} || '');
2982
    return $id ? "$id.madagascar.xdev.ro" : '';
2983
}
2984

            
2985
sub legacy_id_from_fqdn {
2986
    my ($fqdn) = @_;
2987
    $fqdn = normalize_dns_name($fqdn);
2988
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2989
    $fqdn =~ s/\..*\z//;
2990
    return clean_id($fqdn);
2991
}
2992

            
2993
sub normalize_dns_name {
2994
    my ($name) = @_;
2995
    $name = lc clean_scalar($name || '');
2996
    $name =~ s/\.\z//;
2997
    return $name;
2998
}
2999

            
3000
sub name_is_vhost {
3001
    my ($name) = @_;
3002
    $name = normalize_dns_name($name);
3003
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
3004
}
3005

            
Bogdan Timofte authored 4 days ago
3006
sub vhost_name_is_valid {
3007
    my ($name) = @_;
3008
    $name = normalize_dns_name($name);
3009
    return 0 unless length $name;
3010
    return 0 unless $name eq 'madagascar.xdev.ro' || $name =~ /\.madagascar\.xdev\.ro\z/;
3011
    return 0 unless length($name) <= 253;
3012
    for my $label (split /\./, $name) {
3013
        return 0 unless length($label) >= 1 && length($label) <= 63;
3014
        return 0 unless $label =~ /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/;
3015
    }
3016
    return 1;
3017
}
3018

            
Bogdan Timofte authored 4 days ago
3019
sub vhost_service_name {
3020
    my ($name) = @_;
3021
    $name = normalize_dns_name($name);
3022
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
3023
    return '';
3024
}
3025

            
3026
sub short_alias_for_fqdn {
3027
    my ($name) = @_;
3028
    $name = normalize_dns_name($name);
3029
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
3030
    return '';
3031
}
3032

            
Bogdan Timofte authored 4 days ago
3033
sub normalize_registry_policy {
3034
    my ($registry) = @_;
3035
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
3036
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
3037
    $registry->{policy}{runtime_database} = $opt{db};
3038
}
3039

            
3040
sub default_hosts_yaml {
3041
    return <<'YAML';
3042
version: 1
3043
updated_at: ""
3044
policy:
Bogdan Timofte authored 4 days ago
3045
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
3046
hosts:
3047
YAML
3048
}
3049

            
3050
sub default_work_orders_yaml {
3051
    return <<'YAML';
3052
version: 1
3053
work_orders:
3054
YAML
3055
}
3056

            
3057
sub ensure_parent_dir {
3058
    my ($path) = @_;
3059
    my $dir = dirname($path);
3060
    make_path($dir) unless -d $dir;
3061
}
3062

            
Xdev Host Manager authored a week ago
3063
sub url_decode {
3064
    my ($value) = @_;
3065
    $value = '' unless defined $value;
3066
    $value =~ tr/+/ /;
3067
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
3068
    return $value;
3069
}
3070

            
3071
sub random_hex {
3072
    my ($bytes) = @_;
3073
    if (open my $fh, '<:raw', '/dev/urandom') {
3074
        read($fh, my $raw, $bytes);
3075
        close $fh;
3076
        return unpack('H*', $raw);
3077
    }
3078
    return sha256_hex(rand() . time() . $$);
3079
}
3080

            
3081
sub iso_now {
3082
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
3083
}
3084

            
Bogdan Timofte authored 6 days ago
3085
sub build_info {
3086
    my %info = (
3087
        revision => '',
3088
        branch => '',
3089
        built_at => '',
3090
        deployed_at => '',
3091
        dirty => '',
3092
    );
3093

            
3094
    if ($ENV{HOST_MANAGER_BUILD}) {
3095
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
3096
        return \%info;
3097
    }
3098

            
3099
    my $build_file = "$project_dir/BUILD";
3100
    if (-f $build_file) {
3101
        for my $line (split /\n/, read_file($build_file)) {
3102
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
3103
            $info{$1} = clean_scalar($2);
3104
        }
3105
        return \%info if $info{revision} || $info{built_at};
3106
    }
3107

            
3108
    my $revision = git_value('rev-parse --short=12 HEAD');
3109
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
3110
    $info{revision} = $revision if $revision;
3111
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
3112
    return \%info;
3113
}
3114

            
3115
sub git_value {
3116
    my ($args) = @_;
3117
    return '' unless -d "$project_dir/.git";
3118
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
3119
    my $value = <$fh> || '';
3120
    close $fh;
3121
    chomp $value;
3122
    return clean_scalar($value);
3123
}
3124

            
3125
sub build_label {
3126
    my $info = build_info();
3127
    my $revision = $info->{revision} || 'unknown';
3128
    my $branch = $info->{branch} || '';
3129
    $branch = '' if $branch eq 'HEAD';
3130
    my $label = $branch ? "$branch $revision" : $revision;
3131
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
3132
    return $label;
3133
}
3134

            
3135
sub build_title {
3136
    my $info = build_info();
3137
    my $label = build_label();
3138
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
3139
    return $stamp ? "$label deployed $stamp" : $label;
3140
}
3141

            
Bogdan Timofte authored 4 days ago
3142
sub build_revision {
3143
    my $info = build_info();
3144
    return $info->{revision} || 'unknown';
3145
}
3146

            
3147
sub build_details {
3148
    my $info = build_info();
3149
    my %details = (
3150
        app => 'Madagascar Local Authority',
3151
        revision => $info->{revision} || 'unknown',
3152
        branch => $info->{branch} || '',
3153
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
3154
        built_at => $info->{built_at} || '',
3155
        deployed_at => $info->{deployed_at} || '',
3156
        label => build_label(),
3157
        title => build_title(),
3158
    );
3159
    return json_encode(\%details);
3160
}
3161

            
Bogdan Timofte authored 6 days ago
3162
sub html_escape {
3163
    my ($value) = @_;
3164
    $value = '' unless defined $value;
3165
    $value =~ s/&/&amp;/g;
3166
    $value =~ s/</&lt;/g;
3167
    $value =~ s/>/&gt;/g;
3168
    $value =~ s/"/&quot;/g;
3169
    $value =~ s/'/&#039;/g;
3170
    return $value;
3171
}
3172

            
Xdev Host Manager authored a week ago
3173
sub app_html {
Bogdan Timofte authored 4 days ago
3174
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
3175
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
3176
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
3177
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
3178
<!doctype html>
3179
<html lang="ro">
3180
<head>
3181
  <meta charset="utf-8">
3182
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
3183
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
3184
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
3185
  <style>
3186
    :root {
3187
      color-scheme: light;
3188
      --ink: #152033;
3189
      --muted: #647084;
3190
      --line: #d8dee8;
3191
      --soft: #f4f6f9;
3192
      --panel: #ffffff;
3193
      --accent: #1267d8;
3194
      --bad: #b42318;
3195
      --warn: #946200;
3196
      --ok: #137333;
3197
    }
3198
    * { box-sizing: border-box; }
3199
    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
3200

            
3201
    /* ── Login screen ── */
3202
    #login-screen {
3203
      display: flex;
Xdev Host Manager authored a week ago
3204
      align-items: flex-start;
Xdev Host Manager authored a week ago
3205
      justify-content: center;
3206
      min-height: 100dvh;
Xdev Host Manager authored a week ago
3207
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
3208
      background: #13182a;
Xdev Host Manager authored a week ago
3209
      overflow: auto;
Xdev Host Manager authored a week ago
3210
    }
3211
    .login-card {
Xdev Host Manager authored a week ago
3212
      --otp-size: 48px;
Xdev Host Manager authored a week ago
3213
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
3214
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
3215
      background: #fff;
3216
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
3217
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
3218
         below the first box, sits inside the card instead of spilling past it. */
3219
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
3220
      width: 100%;
Xdev Host Manager authored a week ago
3221
      max-width: 680px;
Bogdan Timofte authored 6 days ago
3222
      min-height: 360px;
Xdev Host Manager authored a week ago
3223
      display: grid;
Xdev Host Manager authored a week ago
3224
      align-content: start;
3225
      justify-items: center;
3226
      gap: 28px;
Xdev Host Manager authored a week ago
3227
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
3228
    }
Xdev Host Manager authored a week ago
3229
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
3230
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
3231
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
3232
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
3233
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
3234
    }
Xdev Host Manager authored a week ago
3235
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
3236
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
3237
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
3238
    .login-card form {
3239
      display: grid;
3240
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
3241
      justify-self: center;
Bogdan Timofte authored a week ago
3242
      padding-bottom: 0;
Xdev Host Manager authored a week ago
3243
    }
Xdev Host Manager authored a week ago
3244
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
3245
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
3246
       giving the password manager a username anchor and an aggregated OTP target
3247
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
3248
    .pm-helper-fields {
3249
      position: absolute;
3250
      left: -10000px;
3251
      top: auto;
3252
      width: 1px;
3253
      height: 1px;
3254
      overflow: hidden;
3255
      opacity: 0.01;
3256
    }
3257
    .pm-helper-fields input {
3258
      width: 1px;
3259
      height: 1px;
3260
      padding: 0;
3261
      border: 0;
3262
    }
Bogdan Timofte authored 4 days ago
3263
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
3264
       hint was what made Safari mark the whole group and re-present its OTP
3265
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
3266
    .otp-row {
3267
      display: flex;
3268
      gap: var(--otp-gap);
3269
      justify-content: center;
3270
    }
Bogdan Timofte authored 5 days ago
3271
    .otp-row input {
Xdev Host Manager authored a week ago
3272
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
3273
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
3274
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
3275
      transition: border-color .15s, background .15s;
3276
    }
Bogdan Timofte authored 5 days ago
3277
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
3278
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
3279
    #login-error {
3280
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
3281
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
3282
    }
3283
    @media (max-width: 760px) {
3284
      .login-card {
Xdev Host Manager authored a week ago
3285
        max-width: 520px;
Xdev Host Manager authored a week ago
3286
        min-height: 0;
Bogdan Timofte authored 4 days ago
3287
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
3288
        gap: 26px;
3289
      }
3290
      .login-card .brand h1 { font-size: 24px; }
3291
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
3292
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3293
    }
Xdev Host Manager authored a week ago
3294
    @media (max-width: 430px) {
3295
      #login-screen { padding: 24px 16px 120px; }
3296
      .login-card {
3297
        --otp-size: 42px;
Xdev Host Manager authored a week ago
3298
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
3299
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
3300
      }
Bogdan Timofte authored 5 days ago
3301
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
3302
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3303
    }
3304
    @media (max-height: 720px) {
3305
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
3306
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
3307
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3308
    }
Xdev Host Manager authored a week ago
3309

            
3310
    /* ── App shell (hidden until authenticated) ── */
3311
    #app { display: none; }
Bogdan Timofte authored 5 days ago
3312
    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
3313
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
3314
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
3315
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
3316
    nav a:hover { color: var(--ink); background: var(--soft); }
3317
    nav a.active { color: var(--accent); background: #e8f0fe; }
3318
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
3319
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
3320
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
3321
    .page { display: grid; gap: 16px; }
3322
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
3323
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
3324
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
3325
    .panel { overflow: hidden; }
3326
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
3327
    .panel-head h2 { margin: 0; font-size: 14px; }
3328
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
3329
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
3330
    button, input, select, textarea { font: inherit; }
3331
    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; }
3332
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
3333
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
3334
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
3335
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
3336
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
3337
    textarea { min-height: 74px; resize: vertical; }
3338
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
3339
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
3340
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
3341
    tr:hover td { background: #f8fafc; }
3342
    .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; }
3343
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
3344
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
3345
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
3346
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
3347
    .pill.canonical { font-weight: 700; }
3348
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
3349
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
3350
    .span2 { grid-column: 1 / -1; }
3351
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
3352
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
3353
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
3354
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
3355
    .ca-fingerprint { overflow-wrap: anywhere; }
3356
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
3357
    .build-control {
Bogdan Timofte authored 6 days ago
3358
      position: fixed;
3359
      right: 10px;
3360
      bottom: 8px;
3361
      z-index: 5;
Bogdan Timofte authored 4 days ago
3362
      display: inline-flex;
3363
      align-items: center;
3364
      gap: 4px;
3365
    }
3366
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
3367
      color: rgba(255,255,255,.46);
3368
      background: rgba(19,24,42,.28);
3369
      border: 1px solid rgba(255,255,255,.08);
3370
      border-radius: 4px;
3371
      font-size: 10px;
3372
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
3373
    }
3374
    .build-badge {
3375
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
3376
      cursor: text;
3377
      user-select: text;
Bogdan Timofte authored 6 days ago
3378
    }
Bogdan Timofte authored 4 days ago
3379
    .build-copy {
3380
      min-height: 0;
3381
      padding: 2px 5px;
3382
      cursor: pointer;
3383
    }
3384
    .build-copy:hover {
3385
      color: rgba(255,255,255,.72);
3386
      border-color: rgba(255,255,255,.24);
3387
    }
3388
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
3389
      color: rgba(100,112,132,.58);
3390
      background: rgba(255,255,255,.72);
3391
      border-color: rgba(216,222,232,.72);
3392
    }
Bogdan Timofte authored 4 days ago
3393
    body.is-app .build-copy:hover {
3394
      color: rgba(21,32,51,.78);
3395
      border-color: rgba(100,112,132,.42);
3396
    }
Xdev Host Manager authored a week ago
3397
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
3398
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
3399
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
3400
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
3401
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
3402
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
3403
    .work-order-actions { gap: 4px; }
3404
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
3405
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
3406
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
3407
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
3408
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
3409
    .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
3410
    .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
3411
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
3412
    .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
3413
    .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; }
3414
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
3415
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
3416
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3417
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
3418
    .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; }
3419
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
3420
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
3421
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3422
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
3423
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
3424
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
3425
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 4 days ago
3426
    .host-alias-cell { display: grid; gap: 5px; min-width: 0; }
3427
    .host-alias-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3428
    .host-alias-pill { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin: 0; }
3429
    .host-alias-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3430
    .host-alias-remove, .host-alias-add { min-height: 28px; padding: 3px 7px; font-size: 12px; }
3431
    .host-alias-remove { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--bad); }
3432
    .host-alias-remove:hover { background: transparent; }
Bogdan Timofte authored 3 days ago
3433
    .iconbtn { min-width: 34px; width: 34px; justify-content: center; padding: 7px; }
3434
    .iconbtn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
3435
    .host-actions { display: flex; align-items: center; gap: 6px; }
Bogdan Timofte authored 3 days ago
3436
    .field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
Bogdan Timofte authored 4 days ago
3437
    .host-cert-cell { min-width: 0; }
Bogdan Timofte authored 4 days ago
3438
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3439
    #page-vhosts .host-tools { flex-wrap: wrap; }
3440
    #page-vhosts .host-tools input { max-width: 280px; }
3441
    #page-vhosts .stats { justify-content: flex-end; }
Bogdan Timofte authored 4 days ago
3442
    #page-vhosts .table-wrap { overflow-x: visible; }
3443
    #page-vhosts table { min-width: 0; }
Bogdan Timofte authored 4 days ago
3444
    #page-vhosts th, #page-vhosts td { overflow-wrap: normal; }
3445
    #page-vhosts .pill.vhost { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; }
Bogdan Timofte authored 4 days ago
3446
    .vhost-name-cell { display: grid; gap: 5px; min-width: 0; }
3447
    .vhost-name-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-width: 0; }
3448
    .vhost-delete { min-height: 28px; padding: 3px 7px; color: var(--bad); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3449
    .vhost-host { display: grid; gap: 2px; }
3450
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
3451
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
3452
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 4 days ago
3453
    .vhost-cert { display: grid; gap: 5px; min-width: 0; }
3454
    .vhost-cert-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; }
3455
    .vhost-cert-select { width: 100%; max-width: 100%; min-height: 34px; }
3456
    .vhost-cert-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
3457
    .vhost-cert-links { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
3458
    .vhost-cert-links .linkbtn { padding: 3px 7px; font-size: 12px; }
3459
    .vhost-cert-validity { font-size: 12px; }
Bogdan Timofte authored 4 days ago
3460
    .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
3461
    .host-inline-row td { padding: 0; background: #fff; }
3462
    .host-inline-editor-shell { background: #fff; }
3463
    .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; }
3464
    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3465
    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
3466
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
3467
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
3468
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
3469
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
3470
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
3471
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
3472
      #message { max-width: 100%; }
3473
      .panel-head { align-items: stretch; flex-direction: column; }
3474
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
3475
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
3476
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 4 days ago
3477
      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3478
      .host-inline-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
3479
      .debug-controls { align-items: stretch; }
Xdev Host Manager authored a week ago
3480
      .grid { grid-template-columns: 1fr; }
3481
      table { min-width: 760px; }
3482
      .table-wrap { overflow-x: auto; }
3483
    }
3484
  </style>
3485
</head>
Bogdan Timofte authored 6 days ago
3486
<body class="is-login">
Xdev Host Manager authored a week ago
3487

            
Xdev Host Manager authored a week ago
3488
  <!-- ── Login screen ── -->
3489
  <div id="login-screen">
3490
    <div class="login-card">
3491
      <div class="brand">
3492
        <div class="icon">
Xdev Host Manager authored a week ago
3493
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3494
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3495
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3496
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3497
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3498
            <path d="M26 20h8M26 32h8M26 44h8"/>
3499
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3500
          </svg>
3501
        </div>
Xdev Host Manager authored a week ago
3502
        <h1>Madagascar Local Authority</h1>
3503
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3504
      </div>
Bogdan Timofte authored 4 days ago
3505
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3506
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3507
        <div class="pm-helper-fields" aria-hidden="true">
3508
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3509
          <input type="hidden" id="otp-hidden" name="otp">
3510
        </div>
Xdev Host Manager authored a week ago
3511
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3512
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3513
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3514
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3515
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3516
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3517
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3518
        </div>
3519
      </form>
3520
    </div>
3521
  </div>
3522

            
3523
  <!-- ── App (shown after login) ── -->
3524
  <div id="app">
3525
    <header>
Xdev Host Manager authored a week ago
3526
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3527
      <nav aria-label="Sections">
3528
        <a href="/overview" data-page-link="overview">Overview</a>
3529
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3530
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3531
        <a href="/dns" data-page-link="dns">DNS</a>
3532
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3533
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3534
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3535
      </nav>
Xdev Host Manager authored a week ago
3536
      <div class="header-right">
3537
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3538
        <span id="message" class="muted"></span>
3539
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3540
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3541
      </div>
Xdev Host Manager authored a week ago
3542
    </header>
3543
    <main>
Bogdan Timofte authored 5 days ago
3544
      <section class="page" id="page-overview" data-page="overview">
3545
        <section class="panel">
3546
          <div class="panel-head">
3547
            <h2>Overview</h2>
3548
            <div class="stats" id="stats"></div>
3549
          </div>
3550
          <div class="problems" id="problems"></div>
3551
        </section>
Xdev Host Manager authored a week ago
3552
      </section>
3553

            
Bogdan Timofte authored 5 days ago
3554
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3555
        <section class="panel">
3556
          <div class="panel-head">
3557
            <h2>Hosts</h2>
3558
            <div class="host-tools">
3559
              <input id="filter" placeholder="filter">
3560
              <button type="button" id="new-host">New host</button>
3561
            </div>
3562
          </div>
3563
          <div class="table-wrap">
3564
            <table>
3565
              <thead>
3566
                <tr>
Bogdan Timofte authored 3 days ago
3567
                  <th style="width: 280px">Host</th>
Bogdan Timofte authored 4 days ago
3568
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 4 days ago
3569
                  <th>Aliases</th>
Bogdan Timofte authored 5 days ago
3570
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored 4 days ago
3571
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 5 days ago
3572
                  <th style="width: 110px">Monitoring</th>
3573
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3574
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3575
                </tr>
3576
              </thead>
3577
              <tbody id="hosts"></tbody>
3578
            </table>
3579
          </div>
3580
        </section>
Xdev Host Manager authored a week ago
3581
      </section>
Xdev Host Manager authored a week ago
3582

            
Bogdan Timofte authored 4 days ago
3583
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3584
        <section class="panel">
3585
          <div class="panel-head">
3586
            <h2>Vhosts</h2>
3587
            <div class="host-tools">
3588
              <input id="vhost-filter" placeholder="filter">
3589
              <div class="stats" id="vhost-stats"></div>
3590
            </div>
3591
          </div>
Bogdan Timofte authored 4 days ago
3592
          <div class="vhost-inline-editor">
3593
            <input id="vhost-new-name" placeholder="vhost fqdn">
3594
            <select id="vhost-new-host"></select>
3595
            <button type="button" id="vhost-add">Add</button>
3596
          </div>
Bogdan Timofte authored 4 days ago
3597
          <div class="table-wrap">
3598
            <table>
3599
              <thead>
3600
                <tr>
Bogdan Timofte authored 4 days ago
3601
                  <th style="width: 22%">Vhost</th>
Bogdan Timofte authored 4 days ago
3602
                  <th style="width: 28%">Host</th>
3603
                  <th style="width: 34%">Certificate</th>
Bogdan Timofte authored 4 days ago
3604
                  <th style="width: 8%">Monitoring</th>
3605
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
3606
                </tr>
3607
              </thead>
3608
              <tbody id="vhosts"></tbody>
3609
            </table>
3610
          </div>
3611
        </section>
3612
      </section>
3613

            
Bogdan Timofte authored 5 days ago
3614
      <section class="page" id="page-dns" data-page="dns" hidden>
3615
        <section class="toolbar">
3616
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3617
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3618
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3619
          <button id="write-tsv">Write local-hosts.tsv</button>
3620
        </section>
Xdev Host Manager authored a week ago
3621
      </section>
3622

            
Bogdan Timofte authored 5 days ago
3623
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3624
        <section class="panel">
3625
          <div class="panel-head">
3626
            <h2>Work Orders</h2>
3627
            <div class="stats" id="wo-stats"></div>
3628
          </div>
3629
          <div class="problems" id="work-orders"></div>
3630
        </section>
Xdev Host Manager authored a week ago
3631
      </section>
3632

            
Bogdan Timofte authored 5 days ago
3633
      <section class="page" id="page-ca" data-page="ca" hidden>
3634
        <section class="panel">
3635
          <div class="panel-head">
3636
            <h2>Local Certificate Authority</h2>
3637
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3638
          </div>
3639
          <div class="problems" id="ca-status"></div>
3640
        </section>
3641
        <section class="panel">
3642
          <div class="panel-head">
3643
            <h2>Issued Certificates</h2>
3644
            <div class="stats" id="ca-certs-summary"></div>
3645
          </div>
3646
          <div class="table-wrap">
3647
            <table>
3648
              <thead>
3649
                <tr>
3650
                  <th style="width: 150px">Name</th>
3651
                  <th>DNS names</th>
3652
                  <th style="width: 210px">Validity</th>
3653
                  <th style="width: 180px">Serial</th>
3654
                  <th>Fingerprint</th>
3655
                  <th style="width: 110px">Download</th>
3656
                </tr>
3657
              </thead>
3658
              <tbody id="ca-certs"></tbody>
3659
            </table>
3660
          </div>
3661
        </section>
Xdev Host Manager authored a week ago
3662
      </section>
Bogdan Timofte authored 4 days ago
3663

            
3664
      <section class="page" id="page-debug" data-page="debug" hidden>
3665
        <section class="panel">
3666
          <div class="panel-head">
3667
            <h2>Database</h2>
3668
            <div class="stats" id="debug-db-stats"></div>
3669
          </div>
3670
          <div class="toolbar">
3671
            <div class="debug-controls">
3672
              <button type="button" id="debug-db-refresh">Refresh</button>
3673
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3674
            </div>
3675
          </div>
Bogdan Timofte authored 4 days ago
3676
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3677
        </section>
3678
        <section class="debug-section">
3679
          <section class="panel">
3680
            <div class="panel-head">
3681
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3682
              <div class="debug-table-head-actions">
3683
                <div class="stats" id="debug-table-stats"></div>
3684
                <div class="debug-table-exports">
3685
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3686
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3687
                </div>
3688
              </div>
Bogdan Timofte authored 4 days ago
3689
            </div>
3690
            <div class="table-wrap" id="debug-table-rows"></div>
3691
          </section>
3692
          <section class="panel">
3693
            <div class="panel-head">
3694
              <h2>Columns</h2>
3695
            </div>
3696
            <div class="table-wrap" id="debug-table-columns"></div>
3697
          </section>
3698
          <section class="panel">
3699
            <div class="panel-head">
3700
              <h2>Indexes</h2>
3701
            </div>
3702
            <div class="table-wrap" id="debug-table-indexes"></div>
3703
          </section>
3704
          <section class="panel">
3705
            <div class="panel-head">
3706
              <h2>Foreign Keys</h2>
3707
            </div>
3708
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3709
          </section>
3710
        </section>
3711
      </section>
Bogdan Timofte authored 5 days ago
3712
    </main>
Xdev Host Manager authored a week ago
3713

            
3714
  </div>
3715

            
Bogdan Timofte authored 4 days ago
3716
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3717
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3718
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3719
  </div>
Bogdan Timofte authored 6 days ago
3720

            
Xdev Host Manager authored a week ago
3721
  <script>
Bogdan Timofte authored 4 days ago
3722
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3723
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3724
    let hostFormBusy = false;
3725
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3726
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3727

            
3728
    const $ = (id) => document.getElementById(id);
3729
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 4 days ago
3730
    const hostFormShell = document.createElement('div');
3731
    hostFormShell.id = 'host-form-shell';
3732
    hostFormShell.className = 'host-inline-editor-shell';
3733
    hostFormShell.hidden = true;
3734
    hostFormShell.innerHTML = `
3735
      <div class="host-inline-editor-head">
3736
        <h2 id="host-form-title">New host</h2>
3737
        <div class="host-inline-editor-tools">
Bogdan Timofte authored 3 days ago
3738
          <button class="primary" type="submit" id="save-host" form="host-form">Save host</button>
3739
          <button class="danger" type="button" id="delete-host">Delete host</button>
Bogdan Timofte authored 4 days ago
3740
          <button type="button" id="cancel-host-form">Close</button>
3741
        </div>
3742
      </div>
3743
      <form id="host-form" class="grid">
Bogdan Timofte authored 4 days ago
3744
        <label>Legacy ID<input name="id" required></label>
Bogdan Timofte authored 4 days ago
3745
        <label>FQDN<input name="fqdn" required></label>
3746
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3747
        <label>IP<input name="ip" required></label>
Bogdan Timofte authored 3 days ago
3748
        <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
3749
        <label>Roles<input name="roles"></label>
3750
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3751
        <label>Notes<input name="notes"></label>
3752
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3753
      </form>`;
3754
    const hostForm = hostFormShell.querySelector('#host-form');
3755
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3756
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3757
    const saveHostButton = hostFormShell.querySelector('#save-host');
3758
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3759
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
Bogdan Timofte authored 3 days ago
3760
    const hostAddAliasEditorButton = hostFormShell.querySelector('#host-add-alias-editor');
Bogdan Timofte authored 4 days ago
3761
    const hostEditorRow = document.createElement('tr');
3762
    hostEditorRow.className = 'host-inline-row';
3763
    const hostEditorCell = document.createElement('td');
Bogdan Timofte authored 3 days ago
3764
    hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
3765
    hostEditorRow.appendChild(hostEditorCell);
3766
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
3767
    const PAGE_PATHS = {
3768
      '/': 'overview',
3769
      '/overview': 'overview',
3770
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3771
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3772
      '/dns': 'dns',
3773
      '/work-orders': 'work-orders',
3774
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3775
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3776
    };
Xdev Host Manager authored a week ago
3777

            
Bogdan Timofte authored 4 days ago
3778
    function isAuthLost(error) {
3779
      return !!(error && error.authLost);
3780
    }
3781

            
3782
    function authLostError(message) {
3783
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3784
      error.authLost = true;
3785
      return error;
3786
    }
3787

            
3788
    function handleAuthLost(message) {
3789
      state.authenticated = false;
3790
      msg('');
3791
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3792
    }
3793

            
Bogdan Timofte authored 4 days ago
3794
    async function ensureAuthenticated(message) {
3795
      if (!state.authenticated) {
3796
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3797
        return false;
3798
      }
3799
      const session = await api('/api/session');
3800
      state.authenticated = session.authenticated;
3801
      if (!state.authenticated) {
3802
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3803
        return false;
3804
      }
3805
      return true;
3806
    }
3807

            
Xdev Host Manager authored a week ago
3808
    async function api(path, options = {}) {
3809
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3810
      let body = {};
3811
      try {
3812
        body = await res.json();
3813
      } catch (_) {
3814
        body = {};
3815
      }
3816
      const errorCode = body.error || '';
3817
      if (!res.ok) {
3818
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3819
          const error = authLostError();
3820
          handleAuthLost(error.message);
3821
          throw error;
3822
        }
Bogdan Timofte authored 4 days ago
3823
        const error = new Error(body.detail || errorCode || res.statusText);
3824
        error.code = errorCode;
3825
        throw error;
Bogdan Timofte authored 4 days ago
3826
      }
Xdev Host Manager authored a week ago
3827
      return body;
3828
    }
3829

            
Bogdan Timofte authored 5 days ago
3830
    function currentPage() {
3831
      return PAGE_PATHS[window.location.pathname] || 'overview';
3832
    }
3833

            
3834
    function showPage(page, push = false) {
3835
      const target = page || 'overview';
3836
      document.querySelectorAll('[data-page]').forEach(section => {
3837
        section.hidden = section.dataset.page !== target;
3838
      });
3839
      document.querySelectorAll('[data-page-link]').forEach(link => {
3840
        link.classList.toggle('active', link.dataset.pageLink === target);
3841
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3842
      });
3843
      if (push) {
3844
        const href = target === 'overview' ? '/overview' : '/' + target;
3845
        history.pushState({ page: target }, '', href);
3846
      }
Bogdan Timofte authored 4 days ago
3847
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3848
        renderDebugDatabase().catch(e => {
3849
          if (!isAuthLost(e)) msg(e.message);
3850
        });
Bogdan Timofte authored 4 days ago
3851
      }
Bogdan Timofte authored 5 days ago
3852
    }
3853

            
Xdev Host Manager authored a week ago
3854
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3855
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3856
      document.body.classList.remove('is-app');
3857
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3858
      $('app').style.display = 'none';
3859
      $('login-screen').style.display = 'flex';
3860
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3861
      clearOtp();
Xdev Host Manager authored a week ago
3862
    }
3863

            
3864
    function showApp() {
Bogdan Timofte authored 6 days ago
3865
      document.body.classList.remove('is-login');
3866
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3867
      $('login-screen').style.display = 'none';
3868
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3869
      showPage(currentPage());
Xdev Host Manager authored a week ago
3870
    }
3871

            
Xdev Host Manager authored a week ago
3872
    async function refresh() {
3873
      const session = await api('/api/session');
3874
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3875
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3876
      showApp();
Xdev Host Manager authored a week ago
3877
      const data = await api('/api/hosts');
3878
      state.hosts = data.hosts || [];
Bogdan Timofte authored 4 days ago
3879
      state.vhosts = data.vhosts || [];
3880
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3881
      state.problems = data.problems || [];
3882
      render(data);
Xdev Host Manager authored a week ago
3883
      await renderCa();
Xdev Host Manager authored a week ago
3884
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3885
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3886
    }
3887

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

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

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

            
3901
      renderHosts();
Bogdan Timofte authored 4 days ago
3902
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3903
      renderVhosts();
Xdev Host Manager authored a week ago
3904
    }
3905

            
Xdev Host Manager authored a week ago
3906
    async function renderCa() {
3907
      try {
3908
        const status = await api('/api/ca/status');
3909
        if (!status.initialized) {
3910
          $('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
3911
          $('ca-certs-summary').innerHTML = '';
3912
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3913
          return;
3914
        }
3915
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 4 days ago
3916
        state.certificates = certs.map(cert => ({
3917
          ...cert,
3918
          id: cert.id || cert.name || '',
3919
          name: cert.name || cert.id || '',
3920
          has_private_key: !!cert.has_private_key
3921
        }));
Bogdan Timofte authored 5 days ago
3922
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3923
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3924
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3925
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3926
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3927
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3928
            <div>
3929
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3930
              <span>${certs.length} issued certificate(s)</span>
3931
            </div>
Xdev Host Manager authored a week ago
3932
          </div>`;
Bogdan Timofte authored 5 days ago
3933
        $('ca-certs-summary').innerHTML = [
3934
          ['issued', certs.length],
3935
          ['expiring', certs.filter(cert => {
3936
            const days = daysUntil(cert.not_after);
3937
            return days !== null && days >= 0 && days <= 30;
3938
          }).length],
3939
          ['expired', certs.filter(cert => {
3940
            const days = daysUntil(cert.not_after);
3941
            return days !== null && days < 0;
3942
          }).length],
3943
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3944
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3945
          const days = daysUntil(cert.not_after);
3946
          const dnsNames = cert.dns_names || [];
3947
          const dnsHtml = dnsNames.length
3948
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3949
            : '<span class="muted">No DNS SANs reported.</span>';
3950
          return `<tr>
3951
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3952
            <td>${dnsHtml}</td>
3953
            <td>
3954
              <div class="ca-detail">
3955
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3956
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3957
              </div>
3958
            </td>
3959
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3960
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 4 days ago
3961
            <td>
3962
              <div class="vhost-cert-links">
3963
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
3964
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
3965
              </div>
3966
            </td>
Bogdan Timofte authored 5 days ago
3967
          </tr>`;
3968
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3969
      } catch (e) {
Bogdan Timofte authored 4 days ago
3970
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3971
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3972
        $('ca-certs-summary').innerHTML = '';
3973
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3974
      }
3975
    }
3976

            
Bogdan Timofte authored 5 days ago
3977
    function daysUntil(dateText) {
3978
      const time = Date.parse(dateText || '');
3979
      if (!Number.isFinite(time)) return null;
3980
      return Math.ceil((time - Date.now()) / 86400000);
3981
    }
3982

            
3983
    function certStatusClass(days) {
3984
      if (days === null) return '';
3985
      if (days < 0) return 'bad';
3986
      if (days <= 30) return 'warn';
3987
      return 'ok';
3988
    }
3989

            
3990
    function certStatusLabel(days) {
3991
      if (days === null) return 'validity unknown';
3992
      if (days < 0) return 'expired';
3993
      if (days === 0) return 'expires today';
3994
      return `${days}d remaining`;
3995
    }
3996

            
Xdev Host Manager authored a week ago
3997
    async function renderWorkOrders() {
3998
      try {
3999
        const data = await api('/api/work-orders');
4000
        state.workOrders = data.work_orders || [];
4001
        $('wo-stats').innerHTML = [
4002
          ['pending', data.counts.pending],
4003
          ['total', data.counts.work_orders],
4004
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
4005

            
4006
        if (!state.workOrders.length) {
4007
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
4008
          return;
4009
        }
4010

            
4011
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
4012
          const checklist = wo.checklist || [];
4013
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
4014
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
4015
          const checklistHtml = checklist.map(item => {
4016
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
4017
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
4018
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
4019
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
4020
            </label>`;
4021
          }).join('');
Xdev Host Manager authored a week ago
4022
          const actions = (wo.actions || []).map(a => {
4023
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
4024
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
4025
          }).join('');
4026
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
4027
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
4028
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
4029
            : '';
Bogdan Timofte authored 6 days ago
4030
          return `<div class="problem work-order-card">
4031
            <div class="work-order-head">
Xdev Host Manager authored a week ago
4032
              <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
4033
              ${button}
4034
            </div>
Bogdan Timofte authored 6 days ago
4035
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
4036
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
4037
            <div class="work-order-checklist">${checklistHtml}</div>
4038
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
4039
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
4040
          </div>`;
4041
        }).join('');
Xdev Host Manager authored a week ago
4042
        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
4043
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
4044
      } catch (e) {
Bogdan Timofte authored 4 days ago
4045
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
4046
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
4047
      }
4048
    }
4049

            
Bogdan Timofte authored 4 days ago
4050
    async function renderDebugDatabase() {
4051
      if (!state.authenticated) return;
4052
      const data = await api('/api/debug/database/tables');
4053
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
4054
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
4055
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
4056
      $('debug-db-stats').innerHTML = [
4057
        ['tables', data.counts ? data.counts.tables : tables.length],
4058
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
4059
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4060
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
4061
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
4062
      if (selected) {
4063
        await renderDebugTable(selected);
4064
      } else {
4065
        clearDebugTable();
4066
      }
4067
    }
4068

            
Bogdan Timofte authored 4 days ago
4069
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
4070
      $('debug-db-tables').innerHTML = tables.length
4071
        ? tables.map(table => {
4072
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
4073
            const ref = debugTableReference(database, table.name);
4074
            return `<div class="debug-table-card ${active ? 'active' : ''}">
4075
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
4076
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
4077
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
4078
              </button>
Bogdan Timofte authored 4 days ago
4079
              <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
4080
            </div>`;
Bogdan Timofte authored 4 days ago
4081
          }).join('')
4082
        : '<div class="ca-empty muted">No database tables found.</div>';
4083
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4084
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
4085
          if (!isAuthLost(e)) msg(e.message);
4086
        }));
4087
      });
Bogdan Timofte authored 4 days ago
4088
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
4089
        button.addEventListener('click', async () => {
4090
          try {
4091
            await copyText(button.dataset.debugTableRef || '');
4092
            msg('table reference copied');
4093
          } catch (e) {
4094
            msg('copy failed');
4095
          }
4096
        });
4097
      });
4098
    }
4099

            
4100
    function debugTableReference(database, tableName) {
4101
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
4102
    }
4103

            
4104
    async function selectDebugTable(tableName) {
4105
      state.debugTable = tableName || '';
4106
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4107
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
4108
        const card = button.closest('.debug-table-card');
4109
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
4110
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
4111
      });
4112
      if (state.debugTable) await renderDebugTable(state.debugTable);
4113
    }
4114

            
4115
    function clearDebugTable() {
4116
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
4117
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
4118
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4119
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4120
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4121
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
4122
    }
4123

            
4124
    async function renderDebugTable(tableName) {
4125
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
4126
      if (data.error) throw new Error(data.error);
4127
      $('debug-table-stats').innerHTML = [
4128
        ['table', data.table || tableName],
4129
        ['rows', data.row_count || 0],
4130
        ['shown', (data.rows || []).length],
4131
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
4132
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
4133
      renderDebugRows(data);
4134
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
4135
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
4136
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
4137
    }
4138

            
Bogdan Timofte authored 4 days ago
4139
    function updateDebugExportLinks(tableName) {
4140
      const encoded = encodeURIComponent(tableName || '');
4141
      [
4142
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
4143
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
4144
      ].forEach(([id, href]) => {
4145
        const link = $(id);
4146
        const enabled = !!tableName;
4147
        link.href = enabled ? href : '#';
4148
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
4149
      });
4150
    }
4151

            
Bogdan Timofte authored 4 days ago
4152
    function renderDebugRows(data) {
4153
      const rows = data.rows || [];
4154
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
4155
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
4156
    }
4157

            
4158
    function renderDebugObjectTable(rows, preferredKeys) {
4159
      const keys = preferredKeys && preferredKeys.length
4160
        ? preferredKeys
4161
        : Array.from(rows.reduce((set, row) => {
4162
            Object.keys(row || {}).forEach(key => set.add(key));
4163
            return set;
4164
          }, new Set()));
4165
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
4166
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
4167
      const body = rows.length
4168
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
4169
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
4170
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
4171
    }
4172

            
4173
    function debugCell(value) {
4174
      if (value === null || value === undefined) return 'NULL';
4175
      if (Array.isArray(value)) return value.join(', ');
4176
      if (typeof value === 'object') return JSON.stringify(value);
4177
      return String(value);
4178
    }
4179

            
Xdev Host Manager authored a week ago
4180
    async function updateWorkOrderChecklist(id, itemId, checked) {
4181
      try {
4182
        await api('/api/work-orders/checklist', {
4183
          method: 'POST',
4184
          headers: { 'Content-Type': 'application/json' },
4185
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
4186
        });
4187
        msg('work order updated');
4188
        await refresh();
Bogdan Timofte authored 4 days ago
4189
      } catch (e) {
4190
        if (isAuthLost(e)) return;
4191
        msg(e.message);
4192
        await refresh().catch(refreshError => {
4193
          if (!isAuthLost(refreshError)) msg(refreshError.message);
4194
        });
4195
      }
Xdev Host Manager authored a week ago
4196
    }
4197

            
Xdev Host Manager authored a week ago
4198
    async function confirmWorkOrder(id) {
4199
      const typed = prompt(`Type ${id} to confirm this work order`);
4200
      if (typed !== id) return;
4201
      try {
4202
        await api('/api/work-orders/confirm', {
4203
          method: 'POST',
4204
          headers: { 'Content-Type': 'application/json' },
4205
          body: JSON.stringify({ id, confirm: typed })
4206
        });
4207
        msg('work order confirmed; local-hosts.tsv written');
4208
        await refresh();
Bogdan Timofte authored 4 days ago
4209
      } catch (e) {
4210
        if (isAuthLost(e)) return;
4211
        msg(e.message);
4212
      }
Xdev Host Manager authored a week ago
4213
    }
4214

            
Xdev Host Manager authored a week ago
4215
    function renderHosts() {
4216
      const filter = $('filter').value.toLowerCase();
4217
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
4218
        .slice()
Bogdan Timofte authored 4 days ago
4219
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
4220
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
4221
        .map(h => {
4222
          const problems = state.problems.filter(p => p.host_id === h.id);
4223
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 4 days ago
4224
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 3 days ago
4225
            <td><span class="pill canonical" title="${escapeHtml(h.fqdn || '')}">${escapeHtml(h.fqdn || '')}</span></td>
Bogdan Timofte authored 4 days ago
4226
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
4227
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
4228
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored 4 days ago
4229
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
4230
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4231
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 3 days ago
4232
            <td><div class="host-actions">
4233
              <button type="button" class="iconbtn" data-edit="${escapeHtml(h.id)}" title="Edit ${escapeHtml(h.fqdn || h.id || '')}" aria-label="Edit ${escapeHtml(h.fqdn || h.id || '')}">
4234
                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4z"/></svg>
4235
              </button>
4236
              <button type="button" class="iconbtn danger" data-host-delete="${escapeHtml(h.id)}" title="Delete ${escapeHtml(h.fqdn || h.id || '')}" aria-label="Delete ${escapeHtml(h.fqdn || h.id || '')}">
4237
                <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>
4238
              </button>
4239
            </div></td>
Xdev Host Manager authored a week ago
4240
          </tr>`;
4241
        }).join('');
Bogdan Timofte authored 4 days ago
4242
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
4243
        editHost(button.dataset.edit).catch(e => {
4244
          if (!isAuthLost(e)) msg(e.message);
4245
        });
4246
      }));
Bogdan Timofte authored 3 days ago
4247
      document.querySelectorAll('[data-host-delete]').forEach(button => button.addEventListener('click', () => {
4248
        deleteHostInline(button.dataset.hostDelete || '').catch(e => {
Bogdan Timofte authored 4 days ago
4249
          if (!isAuthLost(e)) msg(e.message);
4250
        });
4251
      }));
4252
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4253
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4254
          if (!isAuthLost(e)) msg(e.message);
4255
        });
4256
      }));
4257
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4258
        select.addEventListener('change', () => {
4259
          setHostCertificateFromSelect(select).catch(e => {
4260
            if (!isAuthLost(e)) msg(e.message);
4261
            select.value = select.dataset.currentCertificate || '';
4262
          });
4263
        });
4264
      });
4265
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4266
        button.addEventListener('click', () => {
4267
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4268
            if (!isAuthLost(e)) msg(e.message);
4269
          });
4270
        });
4271
      });
Bogdan Timofte authored 4 days ago
4272
      mountHostEditor();
Xdev Host Manager authored a week ago
4273
    }
4274

            
Bogdan Timofte authored 4 days ago
4275
    function renderHostAliasCell(host) {
4276
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4277
        <span class="host-alias-label">${escapeHtml(name)}</span>
4278
        <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>
4279
      </span>`).join('');
4280
      return `<div class="host-alias-cell">
Bogdan Timofte authored 3 days ago
4281
        <div class="host-alias-list">${aliases}</div>
Bogdan Timofte authored 4 days ago
4282
      </div>`;
4283
    }
4284

            
4285
    function renderHostCertificateCell(host) {
4286
      const cert = host.certificate || {};
Bogdan Timofte authored 4 days ago
4287
      const certId = host.certificate_id || certificateIdOf(cert) || '';
Bogdan Timofte authored 4 days ago
4288
      const row = hostCertificateRow(host);
4289
      const links = certId ? `<div class="vhost-cert-links">
4290
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4291
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4292
      </div>` : '';
4293
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4294
      return `<div class="vhost-cert">
4295
        <div class="vhost-cert-main">
4296
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4297
            ${renderCertificateOptions(certId, row)}
4298
          </select>
4299
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4300
        </div>
4301
        <div class="vhost-cert-meta">${links}${validity}</div>
4302
      </div>`;
4303
    }
4304

            
4305
    function hostCertificateRow(host) {
4306
      return {
4307
        host_fqdn: host.fqdn || '',
4308
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4309
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4310
        certificate_id: host.certificate_id || '',
4311
        certificate: host.certificate || null,
4312
      };
Bogdan Timofte authored 4 days ago
4313
    }
4314

            
4315
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4316
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4317
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4318
        vhost,
4319
        host_id: host.id || '',
4320
        host_fqdn: host.fqdn || '',
4321
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4322
        monitoring: host.monitoring || '',
4323
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4324
        certificate_id: '',
4325
        certificate: null,
Bogdan Timofte authored 4 days ago
4326
      })));
4327
    }
4328

            
4329
    function renderVhosts() {
4330
      const input = $('vhost-filter');
4331
      const filter = input ? input.value.toLowerCase() : '';
4332
      const rows = vhostRows()
4333
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
4334
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
4335
      $('vhost-stats').innerHTML = [
4336
        ['shown', rows.length],
4337
        ['total', vhostRows().length],
4338
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4339
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
Bogdan Timofte authored 4 days ago
4340
        <td>${renderVhostNameCell(row)}</td>
Bogdan Timofte authored 4 days ago
4341
        <td>
4342
          <div class="vhost-host">
4343
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
4344
              ${renderVhostHostOptions(row.host_fqdn)}
4345
            </select>
4346
          </div>
4347
        </td>
Bogdan Timofte authored 4 days ago
4348
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
4349
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
4350
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
4351
      </tr>`).join('') : '<tr><td colspan="5" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
4352
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
4353
        select.addEventListener('change', () => {
4354
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
4355
            if (!isAuthLost(e)) msg(e.message);
4356
            select.value = select.dataset.currentHost || '';
4357
          });
Bogdan Timofte authored 4 days ago
4358
        });
Bogdan Timofte authored 4 days ago
4359
      });
Bogdan Timofte authored 4 days ago
4360
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
4361
        button.addEventListener('click', () => {
4362
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
4363
            if (!isAuthLost(e)) msg(e.message);
4364
          });
4365
        });
4366
      });
Bogdan Timofte authored 4 days ago
4367
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
4368
        select.addEventListener('change', () => {
4369
          setVhostCertificateFromSelect(select).catch(e => {
4370
            if (!isAuthLost(e)) msg(e.message);
4371
            select.value = select.dataset.currentCertificate || '';
4372
          });
4373
        });
4374
      });
4375
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
4376
        button.addEventListener('click', () => {
Bogdan Timofte authored 4 days ago
4377
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 4 days ago
4378
            if (!isAuthLost(e)) msg(e.message);
4379
          });
4380
        });
4381
      });
4382
    }
4383

            
Bogdan Timofte authored 4 days ago
4384
    function renderVhostNameCell(row) {
4385
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4386
      return `<div class="vhost-name-cell">
4387
        <div class="vhost-name-main">
4388
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4389
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4390
        </div>
4391
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4392
      </div>`;
4393
    }
4394

            
Bogdan Timofte authored 4 days ago
4395
    function renderVhostCertificateCell(row) {
4396
      const cert = row.certificate || {};
4397
      const certId = row.certificate_id || cert.id || cert.name || '';
4398
      const links = certId ? `<div class="vhost-cert-links">
4399
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4400
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4401
      </div>` : '';
4402
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4403
      return `<div class="vhost-cert">
4404
        <div class="vhost-cert-main">
4405
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 4 days ago
4406
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 4 days ago
4407
          </select>
4408
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4409
        </div>
4410
        <div class="vhost-cert-meta">${links}${validity}</div>
4411
      </div>`;
Bogdan Timofte authored 4 days ago
4412
    }
4413

            
4414
    function renderVhostEditor() {
4415
      const select = $('vhost-new-host');
4416
      const current = select.value || '';
4417
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4418
    }
4419

            
4420
    function renderVhostHostOptions(selectedHostFqdn) {
4421
      return state.hosts
4422
        .slice()
4423
        .filter(host => (host.status || '') !== 'retired')
4424
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4425
        .map(host => {
4426
          const fqdn = host.fqdn || '';
4427
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4428
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4429
        }).join('');
Bogdan Timofte authored 4 days ago
4430
    }
4431

            
Bogdan Timofte authored 4 days ago
4432
    function renderCertificateOptions(selectedCertificateId, row) {
4433
      const byId = new Map();
4434
      (state.certificates || []).forEach(cert => {
Bogdan Timofte authored 4 days ago
4435
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4436
        if (id) byId.set(id, cert);
4437
      });
4438
      if (row && row.certificate) {
Bogdan Timofte authored 4 days ago
4439
        const id = certificateIdOf(row.certificate);
Bogdan Timofte authored 4 days ago
4440
        if (id && !byId.has(id)) byId.set(id, row.certificate);
4441
      }
4442
      const certs = Array.from(byId.values())
Bogdan Timofte authored 4 days ago
4443
        .filter(cert => certMatchesRow(cert, row) || certificateIdOf(cert) === selectedCertificateId)
Bogdan Timofte authored 4 days ago
4444
        .sort((a, b) => {
4445
          const ar = certRelevance(a, row);
4446
          const br = certRelevance(b, row);
4447
          if (ar !== br) return ar - br;
4448
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4449
        });
Bogdan Timofte authored 4 days ago
4450
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 4 days ago
4451
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4452
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 4 days ago
4453
        const selected = id === selectedCertificateId ? ' selected' : '';
4454
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
4455
      }));
4456
      return options.join('');
4457
    }
4458

            
Bogdan Timofte authored 4 days ago
4459
    function certificateIdOf(cert) {
Bogdan Timofte authored 4 days ago
4460
      return cert ? (cert.id || cert.name || '') : '';
4461
    }
4462

            
4463
    function certDnsNames(cert) {
4464
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4465
        .map(name => String(name || '').toLowerCase())
4466
        .filter(Boolean);
4467
    }
4468

            
4469
    function certRelevance(cert, row) {
4470
      if (!row) return 9;
4471
      const names = new Set(certDnsNames(cert));
Bogdan Timofte authored 4 days ago
4472
      const id = String(certificateIdOf(cert)).toLowerCase();
Bogdan Timofte authored 4 days ago
4473
      const commonName = String(cert.common_name || '').toLowerCase();
4474
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4475
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4476
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 4 days ago
4477
      const aliasNames = []
4478
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4479
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4480
        .map(name => String(name || '').toLowerCase())
4481
        .filter(Boolean);
4482
      if (vhost) {
4483
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4484
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4485
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4486
        return 9;
4487
      }
4488
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4489
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 4 days ago
4490
      return 9;
4491
    }
4492

            
4493
    function certMatchesRow(cert, row) {
4494
      return certRelevance(cert, row) < 9;
4495
    }
4496

            
4497
    function compactCertificateLabel(cert, row) {
4498
      const relevance = certRelevance(cert, row);
4499
      const days = daysUntil(cert.not_after);
4500
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 4 days ago
4501
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 4 days ago
4502
      if (row && row.vhost) {
Bogdan Timofte authored 4 days ago
4503
        if (relevance === 0) return `${name}${suffix}`;
4504
        if (relevance === 1) return `host ${name}${suffix}`;
4505
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4506
      } else {
Bogdan Timofte authored 4 days ago
4507
        if (relevance === 0) return `${name}${suffix}`;
4508
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4509
      }
Bogdan Timofte authored 4 days ago
4510
      return `${shortCertificateName(cert)}${suffix}`;
4511
    }
4512

            
Bogdan Timofte authored 4 days ago
4513
    function certificateDisplayName(cert) {
4514
      const commonName = String(cert.common_name || '').trim();
4515
      if (commonName) return commonName;
4516
      const dnsNames = certDnsNames(cert);
4517
      if (dnsNames.length) return dnsNames[0];
4518
      return shortCertificateName(cert);
4519
    }
4520

            
Bogdan Timofte authored 4 days ago
4521
    function shortCertificateName(cert) {
4522
      const name = String(cert.common_name || cert.name || cert.id || '');
4523
      const suffix = '.madagascar.xdev.ro';
4524
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4525
    }
4526

            
Bogdan Timofte authored 4 days ago
4527
    function shortAliasForFqdn(name) {
4528
      const suffix = '.madagascar.xdev.ro';
4529
      name = String(name || '').toLowerCase();
4530
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 5 days ago
4531
    }
4532

            
Bogdan Timofte authored 4 days ago
4533
    function hostByFqdn(fqdn) {
4534
      fqdn = String(fqdn || '').toLowerCase();
4535
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4536
    }
4537

            
4538
    function hostUpsertPayload(host, overrides = {}) {
4539
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4540
      const payload = {
4541
        id: host.id || '',
4542
        fqdn: host.fqdn || '',
4543
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4544
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4545
        aliases,
4546
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
Bogdan Timofte authored 3 days ago
4547
        sources: [],
Bogdan Timofte authored 4 days ago
4548
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4549
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4550
      };
4551
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4552
      return payload;
4553
    }
4554

            
Bogdan Timofte authored 3 days ago
4555
    function aliasEditorValues() {
4556
      return (hostField('aliases').value || '')
4557
        .split(/[\s,]+/)
4558
        .map(value => String(value || '').trim().toLowerCase())
4559
        .filter(Boolean);
4560
    }
4561

            
4562
    function appendAliasInEditor() {
4563
      const fqdn = String(hostField('fqdn').value || '').trim().toLowerCase();
4564
      const derived = shortAliasForFqdn(fqdn);
4565
      const alias = String(prompt(fqdn ? `Alias nou pentru ${fqdn}` : 'Alias nou', '') || '').trim().toLowerCase();
4566
      if (!alias) return;
4567
      if (fqdn && alias === fqdn) {
4568
        msg('fqdn-ul hostului este deja numele principal');
4569
        return;
4570
      }
4571
      if (derived && alias === derived) {
4572
        msg('aliasul derivat din fqdn se genereaza automat');
4573
        return;
4574
      }
4575
      const aliases = aliasEditorValues();
4576
      if (aliases.includes(alias)) {
4577
        msg(`aliasul ${alias} este deja in editor`);
4578
        return;
4579
      }
4580
      hostField('aliases').value = aliases.concat(alias).join('\n');
4581
      hostField('aliases').dispatchEvent(new Event('input', { bubbles: true }));
4582
      hostField('aliases').focus();
4583
    }
4584

            
Bogdan Timofte authored 4 days ago
4585
    async function addHostAlias(hostFqdn) {
4586
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4587
      const host = hostByFqdn(hostFqdn);
4588
      if (!host) return;
4589
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4590
      if (!alias) return;
4591
      if (alias === String(host.fqdn || '').toLowerCase()) {
4592
        msg('fqdn-ul hostului este deja prezent');
4593
        return;
4594
      }
4595
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4596
      await api('/api/hosts/upsert', {
4597
        method: 'POST',
4598
        headers: { 'Content-Type': 'application/json' },
4599
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4600
      });
4601
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4602
      await refresh();
4603
    }
4604

            
4605
    async function removeHostAlias(hostFqdn, alias) {
4606
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4607
      const host = hostByFqdn(hostFqdn);
4608
      alias = String(alias || '').trim().toLowerCase();
4609
      if (!host || !alias) return;
4610
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4611
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4612
      await api('/api/hosts/upsert', {
4613
        method: 'POST',
4614
        headers: { 'Content-Type': 'application/json' },
4615
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4616
      });
4617
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4618
      await refresh();
4619
    }
4620

            
Bogdan Timofte authored 3 days ago
4621
    async function deleteHostInline(id) {
4622
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4623
      const host = state.hosts.find(entry => String(entry.id || '') === String(id || ''));
4624
      if (!host) return;
4625
      if (!confirm(`Delete ${host.fqdn || host.id || id}?`)) return;
4626
      await api('/api/hosts/delete', {
4627
        method: 'POST',
4628
        headers: { 'Content-Type': 'application/json' },
4629
        body: JSON.stringify({ id: host.id || id }),
4630
      });
4631
      if (hostEditorTarget === String(host.id || '')) closeHostForm(true);
4632
      msg(`host ${host.fqdn || host.id || id} deleted`);
4633
      await refresh();
4634
    }
4635

            
Bogdan Timofte authored 4 days ago
4636
    async function setHostCertificateFromSelect(select) {
4637
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4638
        select.value = select.dataset.currentCertificate || '';
4639
        return;
4640
      }
4641
      const hostFqdn = select.dataset.hostCertSelect || '';
4642
      const certificateId = select.value || '';
4643
      const current = select.dataset.currentCertificate || '';
4644
      if (!hostFqdn || certificateId === current) return;
4645
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4646
        select.value = current;
4647
        return;
4648
      }
4649
      select.disabled = true;
4650
      try {
4651
        await api('/api/hosts/certificate', {
4652
          method: 'POST',
4653
          headers: { 'Content-Type': 'application/json' },
4654
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4655
        });
4656
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4657
        await refresh();
4658
      } finally {
4659
        select.disabled = false;
4660
      }
4661
    }
4662

            
4663
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4664
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4665
      if (!hostFqdn) return;
4666
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4667
      if (button) button.disabled = true;
4668
      try {
4669
        const result = await api('/api/hosts/issue-certificate', {
4670
          method: 'POST',
4671
          headers: { 'Content-Type': 'application/json' },
4672
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4673
        });
4674
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4675
        await refresh();
4676
      } finally {
4677
        if (button) button.disabled = false;
4678
      }
4679
    }
4680

            
Bogdan Timofte authored 4 days ago
4681
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4682
      const vhost = select.dataset.vhostSelect || '';
4683
      const fromHost = select.dataset.currentHost || '';
4684
      const toHost = select.value || '';
4685
      if (!vhost || !toHost || toHost === fromHost) return;
4686
      select.disabled = true;
4687
      try {
4688
        await api('/api/vhosts/reassign', {
4689
          method: 'POST',
4690
          headers: { 'Content-Type': 'application/json' },
4691
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4692
        });
4693
        msg(`vhost ${vhost} moved`);
4694
        await refresh();
4695
      } finally {
4696
        select.disabled = false;
4697
      }
4698
    }
4699

            
Bogdan Timofte authored 4 days ago
4700
    async function addVhostInline() {
4701
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4702
      const nameInput = $('vhost-new-name');
4703
      const hostSelect = $('vhost-new-host');
4704
      const vhost = (nameInput.value || '').trim().toLowerCase();
4705
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 4 days ago
4706
      if (!vhost || !hostFqdn) {
4707
        msg('completeaza vhost si host');
4708
        return;
4709
      }
4710
      if (!isValidVhostName(vhost)) {
4711
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4712
        nameInput.focus();
4713
        return;
4714
      }
4715
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4716
        msg('vhost invalid: numele este deja host real');
4717
        nameInput.focus();
4718
        return;
4719
      }
Bogdan Timofte authored 4 days ago
4720
      $('vhost-add').disabled = true;
4721
      nameInput.disabled = true;
4722
      hostSelect.disabled = true;
4723
      try {
4724
        await api('/api/vhosts/upsert', {
4725
          method: 'POST',
4726
          headers: { 'Content-Type': 'application/json' },
4727
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4728
        });
4729
        nameInput.value = '';
4730
        msg(`vhost ${vhost} saved`);
4731
        await refresh();
Bogdan Timofte authored 4 days ago
4732
      } catch (e) {
4733
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4734
      } finally {
4735
        $('vhost-add').disabled = false;
4736
        nameInput.disabled = false;
4737
        hostSelect.disabled = false;
4738
      }
4739
    }
4740

            
Bogdan Timofte authored 4 days ago
4741
    function isValidVhostName(name) {
4742
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4743
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4744
      if (name.length > 253) return false;
4745
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4746
    }
4747

            
4748
    function vhostErrorMessage(error) {
4749
      const code = error && error.code ? error.code : '';
4750
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4751
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4752
      if (code === 'invalid_target_host') return 'host tinta invalid';
4753
      if (code === 'missing_target_host') return 'alege hostul tinta';
4754
      return error && error.message ? error.message : 'vhost add failed';
4755
    }
4756

            
Bogdan Timofte authored 4 days ago
4757
    async function setVhostCertificateFromSelect(select) {
4758
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4759
        select.value = select.dataset.currentCertificate || '';
4760
        return;
4761
      }
4762
      const vhost = select.dataset.vhostCertSelect || '';
4763
      const certificateId = select.value || '';
4764
      const current = select.dataset.currentCertificate || '';
4765
      if (!vhost || certificateId === current) return;
4766
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4767
        select.value = current;
4768
        return;
4769
      }
4770
      select.disabled = true;
4771
      try {
4772
        await api('/api/vhosts/certificate', {
4773
          method: 'POST',
4774
          headers: { 'Content-Type': 'application/json' },
4775
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4776
        });
4777
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4778
        await refresh();
4779
      } finally {
4780
        select.disabled = false;
4781
      }
4782
    }
4783

            
Bogdan Timofte authored 4 days ago
4784
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4785
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4786
      if (!vhost) return;
4787
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4788
      if (button) button.disabled = true;
4789
      try {
4790
        const result = await api('/api/vhosts/issue-certificate', {
4791
          method: 'POST',
4792
          headers: { 'Content-Type': 'application/json' },
4793
          body: JSON.stringify({ vhost_fqdn: vhost }),
4794
        });
4795
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4796
        await refresh();
4797
      } finally {
4798
        if (button) button.disabled = false;
4799
      }
4800
    }
4801

            
Bogdan Timofte authored 4 days ago
4802
    async function deleteVhostInline(vhost) {
4803
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4804
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4805
      await api('/api/vhosts/delete', {
4806
        method: 'POST',
4807
        headers: { 'Content-Type': 'application/json' },
4808
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4809
      });
4810
      msg(`vhost ${vhost} deleted`);
4811
      await refresh();
4812
    }
4813

            
Bogdan Timofte authored 4 days ago
4814
    async function editHost(id) {
4815
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4816
      const host = state.hosts.find(h => h.id === id);
4817
      if (!host) return;
Bogdan Timofte authored 4 days ago
4818
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4819
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4820
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4821
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4822
      hostField('roles').value = (host.roles || []).join(' ');
Bogdan Timofte authored 4 days ago
4823
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4824
    }
4825

            
Bogdan Timofte authored 4 days ago
4826
    async function newHost() {
4827
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4828
      if (!canSwitchHostEditor('__new__')) return;
4829
      resetHostForm(true);
4830
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4831
    }
4832

            
Bogdan Timofte authored 4 days ago
4833
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4834
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4835
      hostEditorTarget = target || '';
4836
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4837
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4838
      renderHosts();
4839
      hostFormSnapshot = hostFormState();
4840
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4841
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4842
    }
4843

            
Bogdan Timofte authored 4 days ago
4844
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4845
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4846
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4847
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4848
      hostField('status').value = 'active';
4849
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4850
      hostFormSnapshot = force ? '' : hostFormState();
4851
    }
4852

            
4853
    function closeHostForm(force = false) {
4854
      if (hostFormBusy && !force) return;
4855
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4856
      hostEditorTarget = '';
4857
      hostFormMode = 'new';
4858
      hostFormSnapshot = '';
4859
      clearHostFormMessage();
4860
      syncHostFormActions();
4861
      mountHostEditor();
4862
    }
4863

            
4864
    function canSwitchHostEditor(target) {
4865
      if (hostFormBusy) return false;
4866
      if (!hostEditorTarget) return true;
4867
      if (!hostFormDirty()) return true;
4868
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4869
      return confirm('Discard unsaved host changes?');
4870
    }
4871

            
4872
    function mountHostEditor() {
4873
      hostEditorRow.remove();
4874
      if (!hostEditorTarget) {
4875
        hostFormShell.hidden = true;
4876
        return;
4877
      }
Bogdan Timofte authored 3 days ago
4878
      hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
4879
      const tbody = $('hosts');
4880
      if (!tbody) return;
4881
      if (hostEditorTarget === '__new__') {
4882
        tbody.prepend(hostEditorRow);
4883
      } else {
4884
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4885
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4886
        if (targetRow) targetRow.after(hostEditorRow);
4887
        else tbody.prepend(hostEditorRow);
4888
      }
4889
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4890
    }
4891

            
4892
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4893
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4894
    }
4895

            
4896
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4897
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4898
    }
4899

            
4900
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4901
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4902
    }
4903

            
4904
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4905
      hostFormBusy = !!busy;
4906
      syncHostFormActions();
4907
    }
4908

            
4909
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4910
      saveHostButton.disabled = hostFormBusy;
4911
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4912
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
4913
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4914
    }
4915

            
4916
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4917
      hostFormMessage.textContent = text || '';
4918
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4919
    }
4920

            
4921
    function clearHostFormMessage() {
4922
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4923
    }
4924

            
4925
    function formObject(form) {
4926
      return Object.fromEntries(new FormData(form).entries());
4927
    }
4928

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

            
Bogdan Timofte authored 6 days ago
4934
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4935

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

            
4941
    if (loginAccount) {
4942
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4943
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4944
      loginAccount.addEventListener('input', () => {
4945
        const value = (loginAccount.value || '').trim();
4946
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4947
      });
4948
    }
4949

            
Xdev Host Manager authored a week ago
4950
    function setOtpDigit(idx, value) {
4951
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
4952
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4953
      otpDigits[idx].classList.toggle('filled', !!digit);
4954
    }
4955

            
Bogdan Timofte authored 4 days ago
4956
    // Move focus to the next empty box: forward from idx, then wrapping to the
4957
    // start. This lets out-of-order entry continue (e.g. after the last box,
4958
    // jump back to the first still-empty box). Stays put when all boxes are full.
4959
    function advanceFocus(idx) {
4960
      for (let i = idx + 1; i < otpDigits.length; i++) {
4961
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4962
      }
4963
      for (let i = 0; i <= idx; i++) {
4964
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4965
      }
4966
    }
4967

            
Bogdan Timofte authored 5 days ago
4968
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4969
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4970
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
4971
      const digits = (text || '').replace(/\D/g, '').split('');
4972
      if (!digits.length) return;
4973
      let last = startIdx;
4974
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4975
        last = startIdx + i;
4976
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4977
      }
Bogdan Timofte authored 5 days ago
4978
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4979
      advanceFocus(last);
Xdev Host Manager authored a week ago
4980
      maybeSubmitOtp();
4981
    }
4982

            
Bogdan Timofte authored 5 days ago
4983
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4984
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4985
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4986
    function maybeSubmitOtp() {
4987
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4988
    }
4989
    function clearOtp() {
Bogdan Timofte authored 5 days ago
4990
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4991
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4992
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4993
      // an unknown operator, so Safari's autofill anchor on the username stays.
4994
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4995
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4996
    }
4997

            
Bogdan Timofte authored 5 days ago
4998
    otpDigits.forEach((input, idx) => {
4999
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
5000
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5001
        // A single box may receive several digits at once (autofill / typing fast).
5002
        if (input.value.replace(/\D/g, '').length > 1) {
5003
          fillOtp(input.value, idx);
5004
          return;
5005
        }
5006
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
5007
        syncOtpFields();
Bogdan Timofte authored 4 days ago
5008
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
5009
        maybeSubmitOtp();
5010
      });
Bogdan Timofte authored 5 days ago
5011

            
5012
      input.addEventListener('paste', (e) => {
5013
        e.preventDefault();
Bogdan Timofte authored 4 days ago
5014
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5015
        const text = (e.clipboardData || window.clipboardData).getData('text');
5016
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
5017
      });
Bogdan Timofte authored 5 days ago
5018

            
5019
      input.addEventListener('keydown', (e) => {
5020
        if (e.key === 'Backspace') {
5021
          e.preventDefault();
Bogdan Timofte authored 4 days ago
5022
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5023
          if (input.value) { setOtpDigit(idx, ''); }
5024
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
5025
          syncOtpFields();
5026
        } else if (e.key === 'ArrowLeft' && idx > 0) {
5027
          e.preventDefault();
5028
          otpDigits[idx - 1].focus();
5029
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
5030
          e.preventDefault();
5031
          otpDigits[idx + 1].focus();
5032
        }
5033
      });
5034
    });
5035

            
Bogdan Timofte authored 4 days ago
5036
    // Focus the first OTP box only for a returning operator (username known).
5037
    // For an unknown operator, leave focus on the username field so Safari can
5038
    // present its OTP autofill anchored there without being dismissed by a focus
5039
    // change (pbx-admin pattern).
5040
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
5041
    else if (loginAccount) loginAccount.focus();
5042
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
5043

            
Bogdan Timofte authored 5 days ago
5044
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
5045
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
5046
        event.preventDefault();
Bogdan Timofte authored 4 days ago
5047
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
5048
        showPage(link.dataset.pageLink, true);
5049
      });
5050
    });
5051

            
Bogdan Timofte authored 4 days ago
5052
    window.addEventListener('popstate', () => {
5053
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
5054
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
5055
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
5056
    });
Bogdan Timofte authored 5 days ago
5057

            
Bogdan Timofte authored 4 days ago
5058
    async function copyText(text) {
5059
      if (navigator.clipboard && window.isSecureContext) {
5060
        await navigator.clipboard.writeText(text);
5061
        return;
5062
      }
5063
      const input = document.createElement('textarea');
5064
      input.value = text;
5065
      input.setAttribute('readonly', '');
5066
      input.style.position = 'fixed';
5067
      input.style.left = '-10000px';
5068
      document.body.appendChild(input);
5069
      input.select();
5070
      document.execCommand('copy');
5071
      document.body.removeChild(input);
5072
    }
5073

            
5074
    $('copy-build').addEventListener('click', async () => {
5075
      try {
5076
        await copyText($('copy-build').dataset.buildDetails || '');
5077
        if (state.authenticated) msg('build details copied');
5078
      } catch (e) {
5079
        if (state.authenticated) msg('copy failed');
5080
      }
5081
    });
5082

            
Xdev Host Manager authored a week ago
5083
    $('login-form').addEventListener('submit', async (event) => {
5084
      event.preventDefault();
Bogdan Timofte authored 5 days ago
5085
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
5086
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
5087
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
5088
      try {
Xdev Host Manager authored a week ago
5089
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
5090
        await refresh();
Xdev Host Manager authored a week ago
5091
      } catch (e) {
5092
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
5093
      } finally {
Xdev Host Manager authored a week ago
5094
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
5095
      }
Xdev Host Manager authored a week ago
5096
    });
5097

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

            
Bogdan Timofte authored 4 days ago
5103
    $('refresh').addEventListener('click', () => refresh().catch(e => {
5104
      if (!isAuthLost(e)) msg(e.message);
5105
    }));
Xdev Host Manager authored a week ago
5106
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
5107
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
5108
    $('vhost-add').addEventListener('click', () => {
5109
      addVhostInline().catch(e => {
5110
        if (!isAuthLost(e)) msg(e.message);
5111
      });
5112
    });
5113
    $('vhost-new-name').addEventListener('keydown', (event) => {
5114
      if (event.key !== 'Enter') return;
5115
      event.preventDefault();
5116
      addVhostInline().catch(e => {
5117
        if (!isAuthLost(e)) msg(e.message);
5118
      });
5119
    });
Bogdan Timofte authored 4 days ago
5120
    $('new-host').addEventListener('click', () => {
5121
      newHost().catch(e => {
5122
        if (!isAuthLost(e)) msg(e.message);
5123
      });
5124
    });
Bogdan Timofte authored 4 days ago
5125
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
5126
      if (!isAuthLost(e)) msg(e.message);
5127
    }));
Bogdan Timofte authored 3 days ago
5128
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 4 days ago
5129
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
5130

            
Bogdan Timofte authored 4 days ago
5131
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
5132
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5133
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
5134
      setHostFormBusy(true);
5135
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
5136
      try {
5137
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
5138
        msg('host saved');
5139
        await refresh();
Bogdan Timofte authored 3 days ago
5140
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5141
      } catch (e) {
Bogdan Timofte authored 4 days ago
5142
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5143
        setHostFormMessage(e.message, true);
5144
        msg(e.message);
5145
      } finally {
5146
        setHostFormBusy(false);
5147
      }
5148
    });
5149

            
Bogdan Timofte authored 4 days ago
5150
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
5151
      setHostFormMessage('Complete the required host fields before saving.', true);
5152
    }, true);
5153

            
Bogdan Timofte authored 4 days ago
5154
    hostForm.addEventListener('input', () => {
5155
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
5156
    });
5157

            
Bogdan Timofte authored 4 days ago
5158
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
5159
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
5160
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
5161
      setHostFormBusy(true);
5162
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
5163
      try {
5164
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
5165
        msg('host deleted');
5166
        await refresh();
Bogdan Timofte authored 4 days ago
5167
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5168
      } catch (e) {
Bogdan Timofte authored 4 days ago
5169
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5170
        setHostFormMessage(e.message, true);
5171
        msg(e.message);
5172
      } finally {
5173
        setHostFormBusy(false);
5174
      }
Xdev Host Manager authored a week ago
5175
    });
5176

            
Bogdan Timofte authored 4 days ago
5177
    resetHostForm(true);
5178
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
5179

            
Xdev Host Manager authored a week ago
5180
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
5181
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
5182
      try {
5183
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
5184
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
5185
      } catch (e) {
5186
        if (!isAuthLost(e)) msg(e.message);
5187
      }
Xdev Host Manager authored a week ago
5188
    });
5189

            
Bogdan Timofte authored 4 days ago
5190
    refresh().catch(e => {
5191
      if (!isAuthLost(e)) showLogin(e.message);
5192
    });
Xdev Host Manager authored a week ago
5193
  </script>
5194
</body>
5195
</html>
5196
HTML
Bogdan Timofte authored 6 days ago
5197
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
5198
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
5199
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
5200
    return $html;
Xdev Host Manager authored a week ago
5201
}