LocalAuthority / scripts / host_manager.pl
Newer Older
5252 lines | 208.273kb
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",
Bogdan Timofte authored 2 days ago
26
    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/var/local-hosts.tsv",
Bogdan Timofte authored 2 days ago
27
    dns_publish_trigger => $ENV{HOST_MANAGER_DNS_PUBLISH_TRIGGER} || "$project_dir/var/dns-publish.trigger",
Xdev Host Manager authored a week ago
28
    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
Xdev Host Manager authored a week ago
29
);
Bogdan Timofte authored 3 days ago
30
my $print_local_hosts_tsv = 0;
Xdev Host Manager authored a week ago
31

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

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

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

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

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

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

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

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

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

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

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

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

            
131
    my ($path, $query) = split /\?/, $target, 2;
132
    my %query = parse_params($query || '');
133

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 2 days ago
493
    my $publish = save_registry($registry);
Xdev Host Manager authored a week ago
494
    save_work_orders($orders);
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},
Bogdan Timofte authored 2 days ago
501
        dns_publish => $publish,
Xdev Host Manager authored a week ago
502
    });
503
}
504

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

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

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

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

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

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

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

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

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

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

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

            
Xdev Host Manager authored a week ago
734
sub upsert_host {
735
    my ($client, $payload) = @_;
Bogdan Timofte authored 4 days ago
736
    my $ip = canonical_ip($payload);
737
    return send_json($client, 400, { error => 'missing_ip' }) unless $ip;
Xdev Host Manager authored a week ago
738

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

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

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

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

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

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

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

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

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

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

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

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

            
861
    my $result = eval {
862
        with_transaction($dbh, sub {
863
            my $now = iso_now();
864
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
865

            
866
            my $registry = load_registry_from_db();
867
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
868
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
869

            
870
            upsert_host_to_db($dbh, $target_host) if $target_host;
871
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
872
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
873
        });
874
        1;
875
    };
876
    if (!$result) {
877
        my $err = $@ || 'vhost_upsert_failed';
878
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
879
    }
Bogdan Timofte authored 2 days ago
880
    my $publish = publish_dns_change(load_registry(), 'vhost-upsert');
881
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '', dns_publish => $publish });
Bogdan Timofte authored 4 days ago
882
}
883

            
884
sub delete_vhost {
885
    my ($client, $payload) = @_;
886
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
887
    my $confirm = normalize_dns_name($payload->{confirm} || '');
Bogdan Timofte authored 3 days ago
888
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
889
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
890

            
891
    my $dbh = dbh();
892
    my ($current_fqdn) = $dbh->selectrow_array(
893
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
894
        undef,
895
        $vhost,
896
    );
897
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
898

            
899
    my $result = eval {
900
        with_transaction($dbh, sub {
901
            my $now = iso_now();
902
            $dbh->do(
903
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
904
                undef,
905
                $now, $vhost,
906
            );
907

            
908
            my $registry = load_registry_from_db();
909
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
910
            upsert_host_to_db($dbh, $current_host) if $current_host;
911
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
912
        });
913
        1;
914
    };
915
    if (!$result) {
916
        my $err = $@ || 'vhost_delete_failed';
917
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
918
    }
Bogdan Timofte authored 2 days ago
919
    my $publish = publish_dns_change(load_registry(), 'vhost-delete');
920
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn, dns_publish => $publish });
Bogdan Timofte authored 4 days ago
921
}
922

            
Bogdan Timofte authored 4 days ago
923
sub set_host_certificate {
924
    my ($client, $payload) = @_;
925
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
926
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
927
    my $certificate_id = clean_certificate_id($raw_certificate_id);
928
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
929
    return send_json($client, 400, { error => 'invalid_certificate' })
930
        if length($raw_certificate_id) && !length($certificate_id);
931

            
932
    my $dbh = dbh();
933
    return send_json($client, 404, { error => 'host_not_found' })
934
        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
935
    if (length $certificate_id) {
936
        return send_json($client, 400, { error => 'invalid_certificate' })
937
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
938
    }
939

            
940
    my $now = iso_now();
941
    with_transaction($dbh, sub {
942
        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
943
        set_schema_meta($dbh, 'registry_updated_at', $now);
944
    });
945
    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
946
}
947

            
948
sub issue_host_certificate {
949
    my ($client, $payload) = @_;
950
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
951
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
952

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

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

            
978
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
979
    return send_json($client, 200, {
980
        ok => json_bool(1),
981
        host_fqdn => $host_fqdn,
982
        certificate_id => $certificate_id,
983
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
984
    });
985
}
986

            
Bogdan Timofte authored 4 days ago
987
sub set_vhost_certificate {
988
    my ($client, $payload) = @_;
989
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
990
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
991
    my $certificate_id = clean_certificate_id($raw_certificate_id);
Bogdan Timofte authored 3 days ago
992
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
993
    return send_json($client, 400, { error => 'invalid_certificate' })
994
        if length($raw_certificate_id) && !length($certificate_id);
995

            
996
    my $dbh = dbh();
997
    return send_json($client, 404, { error => 'vhost_not_found' })
998
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
999
    if (length $certificate_id) {
1000
        return send_json($client, 400, { error => 'invalid_certificate' })
1001
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
1002
    }
1003

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

            
1018
sub issue_vhost_certificate {
1019
    my ($client, $payload) = @_;
1020
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
Bogdan Timofte authored 3 days ago
1021
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
1022

            
1023
    my $dbh = dbh();
1024
    my ($host_fqdn) = $dbh->selectrow_array(
1025
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
1026
        undef,
1027
        $vhost,
1028
    );
1029
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
1030

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

            
1053
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
1054
    return send_json($client, 200, {
1055
        ok => json_bool(1),
1056
        vhost_fqdn => $vhost,
1057
        host_fqdn => $host_fqdn,
1058
        certificate_id => $certificate_id,
1059
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
1060
    });
1061
}
1062

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

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

            
1106
sub effective_names {
1107
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
1108
    my @names = declared_dns_names($host);
1109
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
1110
    return unique_preserve(@names);
1111
}
1112

            
Bogdan Timofte authored 3 days ago
1113
sub host_dns_names {
1114
    my ($host) = @_;
1115
    my @names;
1116
    my $fqdn = canonical_host_fqdn($host);
1117
    push @names, $fqdn if length $fqdn;
1118
    push @names, declared_alias_names($host), derived_alias_names($host);
1119
    return unique_preserve(@names);
1120
}
1121

            
1122
sub vhost_cname_records {
1123
    my ($host) = @_;
1124
    my $target = canonical_host_fqdn($host);
1125
    return () unless length $target;
1126
    my @records;
1127
    for my $vhost (declared_vhost_names($host)) {
1128
        push @records, [ $vhost, $target ];
1129
        if (my $short = short_alias_for_fqdn($vhost)) {
1130
            push @records, [ $short, $target ];
1131
        }
1132
    }
1133
    my %seen;
1134
    return grep { !$seen{$_->[0]}++ } @records;
1135
}
1136

            
Bogdan Timofte authored 4 days ago
1137
sub declared_dns_names {
1138
    my ($host) = @_;
1139
    my @names;
1140
    my $fqdn = canonical_host_fqdn($host);
1141
    push @names, $fqdn if length $fqdn;
1142
    push @names, declared_alias_names($host);
1143
    push @names, declared_vhost_names($host);
1144
    return unique_preserve(@names);
1145
}
1146

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

            
1152
sub declared_vhost_names {
1153
    my ($host) = @_;
1154
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
1155
}
1156

            
1157
sub declared_dns_names_legacy {
1158
    my ($host) = @_;
1159
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
1160
}
1161

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

            
1192
sub derived_alias_names {
Xdev Host Manager authored a week ago
1193
    my ($host) = @_;
1194
    my @derived;
Bogdan Timofte authored 4 days ago
1195
    my $fqdn = canonical_host_fqdn($host);
1196
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1197
    for my $name (declared_alias_names($host)) {
1198
        push @derived, short_alias_for_fqdn($name);
1199
    }
1200
    return unique_preserve(grep { length $_ } @derived);
1201
}
1202

            
1203
sub derived_vhost_alias_names {
1204
    my ($host) = @_;
1205
    my @derived;
1206
    for my $name (declared_vhost_names($host)) {
1207
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
1208
    }
Bogdan Timofte authored 4 days ago
1209
    return unique_preserve(grep { length $_ } @derived);
1210
}
1211

            
1212
sub clean_alias_names {
1213
    my ($payload) = @_;
1214
    return clean_name_bucket($payload->{aliases})
1215
        if defined $payload->{aliases};
1216
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1217
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1218
}
1219

            
1220
sub clean_vhost_names {
1221
    my ($payload) = @_;
1222
    return clean_name_bucket($payload->{vhosts})
1223
        if defined $payload->{vhosts};
1224
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1225
    return grep { name_is_vhost($_) } @legacy;
1226
}
1227

            
1228
sub clean_name_bucket {
1229
    my ($value) = @_;
1230
    my @names = clean_list($value);
1231
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1232
}
1233

            
1234
sub remove_derived_names {
1235
    my @names = @_;
1236
    my %derived;
1237
    for my $name (@names) {
1238
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1239
        $derived{$1} = 1;
1240
    }
1241
    return grep { !$derived{$_} } @names;
1242
}
1243

            
1244
sub unique_preserve {
1245
    my @values = @_;
1246
    my %seen;
1247
    return grep { !$seen{$_}++ } @values;
1248
}
1249

            
Bogdan Timofte authored 4 days ago
1250
sub canonical_ip {
1251
    my ($host) = @_;
1252
    return '' unless $host && ref($host) eq 'HASH';
1253
    for my $key (qw(ip dns_ip hosts_ip)) {
1254
        my $value = clean_scalar($host->{$key} || '');
1255
        return $value if length $value;
1256
    }
1257
    return '';
1258
}
1259

            
Xdev Host Manager authored a week ago
1260
sub problem {
1261
    my ($host, $code, $message) = @_;
1262
    return { host_id => $host->{id}, code => $code, message => $message };
1263
}
1264

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

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

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

            
1347
sub debug_database_table_payload {
1348
    my ($table, $limit) = @_;
1349
    my $dbh = dbh();
1350
    $table = clean_scalar($table);
1351
    return { error => 'missing_table' } unless length $table;
1352
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1353
    $limit = int($limit || 100);
1354
    $limit = 1 if $limit < 1;
1355
    $limit = 500 if $limit > 500;
1356

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

            
1378
    return {
1379
        database => $opt{db},
1380
        table => $table,
1381
        generated_at => iso_now(),
1382
        limit => $limit,
1383
        row_count => int($row_count || 0),
1384
        columns => $columns,
1385
        indexes => \@index_details,
1386
        foreign_keys => $foreign_keys,
1387
        rows => $rows,
1388
    };
1389
}
1390

            
Bogdan Timofte authored 4 days ago
1391
sub debug_database_table_export_payload {
1392
    my ($table) = @_;
1393
    my $dbh = dbh();
1394
    $table = clean_scalar($table);
1395
    return { error => 'missing_table' } unless length $table;
1396
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1397

            
1398
    my $quoted = $dbh->quote_identifier($table);
1399
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1400
    my @column_names = map { $_->{name} || '' } @$columns;
1401
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1402
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1403

            
1404
    return {
1405
        database => $opt{db},
1406
        table => $table,
1407
        generated_at => iso_now(),
1408
        row_count => int($row_count || 0),
1409
        columns => \@column_names,
1410
        rows => $rows,
1411
    };
1412
}
1413

            
1414
sub render_debug_table_csv {
1415
    my ($export) = @_;
1416
    my @columns = @{ $export->{columns} || [] };
1417
    my @lines = (join(',', map { csv_cell($_) } @columns));
1418
    for my $row (@{ $export->{rows} || [] }) {
1419
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1420
    }
1421
    return join("\n", @lines) . "\n";
1422
}
1423

            
1424
sub csv_cell {
1425
    my ($value) = @_;
1426
    $value = '' unless defined $value;
1427
    $value = "$value";
1428
    $value =~ s/"/""/g;
1429
    return qq("$value") if $value =~ /[",\r\n]/;
1430
    return $value;
1431
}
1432

            
1433
sub debug_table_export_filename {
1434
    my ($table, $extension) = @_;
1435
    $table = clean_scalar($table || 'table');
1436
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1437
    $table = 'table' unless length $table;
1438
    return "debug-$table.$extension";
1439
}
1440

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

            
1452
sub sum {
1453
    my $total = 0;
1454
    $total += $_ || 0 for @_;
1455
    return $total;
1456
}
1457

            
Xdev Host Manager authored a week ago
1458
sub ca_script_path {
1459
    return "$project_dir/scripts/ca_manager.sh";
1460
}
1461

            
1462
sub ca_dir {
1463
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1464
}
1465

            
1466
sub ca_cert_path {
1467
    return ca_dir() . "/certs/ca.cert.pem";
1468
}
1469

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

            
Bogdan Timofte authored 4 days ago
1476
sub ca_issued_key_path {
1477
    my ($name) = @_;
1478
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1479
    return ca_dir() . "/issued/$name.key.pem";
1480
}
1481

            
Bogdan Timofte authored 4 days ago
1482
sub ca_private_key_exists {
1483
    my ($name) = @_;
1484
    return 0 unless clean_certificate_id($name || '');
1485
    return -f ca_issued_key_path($name) ? 1 : 0;
1486
}
1487

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

            
1500
sub ca_manager_json {
1501
    my ($command) = @_;
1502
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1503
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1504
    sync_certificates_from_json($out) if $command eq 'list-json';
1505
    return $out;
1506
}
1507

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

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

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

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

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

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

            
Xdev Host Manager authored a week ago
1756
sub request_payload {
1757
    my ($headers, $body) = @_;
1758
    my $type = $headers->{'content-type'} || '';
1759
    if ($type =~ m{application/json}) {
1760
        return json_decode($body || '{}');
1761
    }
1762
    return { parse_params($body || '') };
1763
}
1764

            
1765
sub json_bool {
1766
    my ($value) = @_;
1767
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1768
}
1769

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

            
1792
sub json_string {
1793
    my ($value) = @_;
1794
    $value = '' unless defined $value;
1795
    $value =~ s/\\/\\\\/g;
1796
    $value =~ s/"/\\"/g;
1797
    $value =~ s/\n/\\n/g;
1798
    $value =~ s/\r/\\r/g;
1799
    $value =~ s/\t/\\t/g;
1800
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1801
    return qq("$value");
1802
}
1803

            
1804
sub json_decode {
1805
    my ($text) = @_;
1806
    my $i = 0;
1807
    my $len = length($text);
1808
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1809

            
1810
    $skip_ws = sub {
1811
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1812
    };
1813

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

            
1851
    $parse_number = sub {
1852
        my $start = $i;
1853
        $i++ if substr($text, $i, 1) eq '-';
1854
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1855
        if ($i < $len && substr($text, $i, 1) eq '.') {
1856
            $i++;
1857
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1858
        }
1859
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1860
            $i++;
1861
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1862
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1863
        }
1864
        return 0 + substr($text, $start, $i - $start);
1865
    };
1866

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

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

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

            
1932
    my $value = $parse_value->();
1933
    $skip_ws->();
1934
    die "Trailing JSON content\n" if $i != $len;
1935
    return $value;
1936
}
1937

            
1938
sub parse_params {
1939
    my ($text) = @_;
1940
    my %out;
1941
    for my $pair (split /&/, $text) {
1942
        next unless length $pair;
1943
        my ($k, $v) = split /=/, $pair, 2;
1944
        $out{url_decode($k)} = url_decode($v || '');
1945
    }
1946
    return %out;
1947
}
1948

            
1949
sub clean_id {
1950
    my ($value) = @_;
1951
    $value = lc clean_scalar($value);
1952
    $value =~ s/[^a-z0-9_.-]+/-/g;
1953
    $value =~ s/^-+|-+$//g;
1954
    return $value;
1955
}
1956

            
Bogdan Timofte authored 4 days ago
1957
sub clean_certificate_id {
1958
    my ($value) = @_;
1959
    $value = clean_scalar($value);
1960
    return '' unless length $value;
1961
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1962
}
1963

            
Bogdan Timofte authored 3 days ago
1964
sub clean_ip {
1965
    my ($value) = @_;
1966
    $value = clean_scalar($value);
1967
    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/;
1968
    return '';
1969
}
1970

            
1971
sub clean_mac {
1972
    my ($value) = @_;
1973
    $value = lc clean_scalar($value);
1974
    $value =~ s/-/:/g;
1975
    return $value if $value =~ /\A[0-9a-f]{2}(?::[0-9a-f]{2}){5}\z/;
1976
    return '';
1977
}
1978

            
1979
sub normalize_dhcp_name {
1980
    my ($value) = @_;
1981
    $value = normalize_dns_name($value || '');
1982
    $value =~ s/[^a-z0-9_.-]+/-/g;
1983
    $value =~ s/^-+|-+$//g;
1984
    return $value;
1985
}
1986

            
Xdev Host Manager authored a week ago
1987
sub clean_scalar {
1988
    my ($value) = @_;
1989
    $value = '' unless defined $value;
1990
    $value =~ s/[\r\n\t]+/ /g;
1991
    $value =~ s/^\s+|\s+$//g;
1992
    return $value;
1993
}
1994

            
1995
sub clean_list {
1996
    my ($value) = @_;
1997
    return () unless defined $value;
1998
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1999
    my @clean;
2000
    for my $item (@items) {
2001
        $item = clean_scalar($item);
2002
        push @clean, $item if length $item;
2003
    }
2004
    return @clean;
2005
}
2006

            
2007
sub yq {
2008
    my ($value) = @_;
2009
    $value = '' unless defined $value;
2010
    $value =~ s/\\/\\\\/g;
2011
    $value =~ s/"/\\"/g;
2012
    return qq("$value");
2013
}
2014

            
2015
sub yaml_unquote {
2016
    my ($value) = @_;
2017
    $value = '' unless defined $value;
2018
    $value =~ s/^\s+|\s+$//g;
2019
    if ($value =~ /^"(.*)"$/) {
2020
        $value = $1;
2021
        $value =~ s/\\"/"/g;
2022
        $value =~ s/\\\\/\\/g;
2023
    }
2024
    return $value;
2025
}
2026

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

            
2039
sub totp_code {
2040
    my ($key, $counter) = @_;
2041
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
2042
    my $hash = hmac_sha1($msg, $key);
2043
    my $offset = ord(substr($hash, -1)) & 0x0f;
2044
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
2045
    return sprintf('%06d', $bin % 1_000_000);
2046
}
2047

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

            
2068
sub create_session {
2069
    my $nonce = random_hex(24);
2070
    my $expires = int(time() + 8 * 3600);
2071
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
2072
    my $token = "$nonce:$expires:$sig";
2073
    $sessions{$token} = $expires;
2074
    return $token;
2075
}
2076

            
2077
sub is_authenticated {
2078
    my ($headers) = @_;
2079
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2080
    return 0 unless $token;
2081
    my ($nonce, $expires, $sig) = split /:/, $token;
2082
    return 0 unless $nonce && $expires && $sig;
2083
    return 0 if $expires < time();
2084
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
2085
    return exists $sessions{$token};
2086
}
2087

            
2088
sub expire_session {
2089
    my ($headers) = @_;
2090
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2091
    delete $sessions{$token} if $token;
2092
}
2093

            
2094
sub cookie_value {
2095
    my ($cookie, $name) = @_;
2096
    for my $part (split /;\s*/, $cookie) {
2097
        my ($k, $v) = split /=/, $part, 2;
2098
        return $v if defined $k && $k eq $name;
2099
    }
2100
    return '';
2101
}
2102

            
2103
sub send_json {
2104
    my ($client, $status, $payload, $extra_headers) = @_;
2105
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
2106
}
2107

            
Xdev Host Manager authored a week ago
2108
sub send_json_raw {
2109
    my ($client, $status, $json_body, $extra_headers) = @_;
2110
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
2111
}
2112

            
Xdev Host Manager authored a week ago
2113
sub send_html {
2114
    my ($client, $status, $html) = @_;
2115
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
2116
}
2117

            
2118
sub send_text {
2119
    my ($client, $status, $text) = @_;
2120
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
2121
}
2122

            
2123
sub send_download {
2124
    my ($client, $status, $content, $type, $filename) = @_;
2125
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
2126
}
2127

            
2128
sub send_file {
2129
    my ($client, $path, $type, $filename) = @_;
2130
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
2131
    return send_download($client, 200, read_file($path), $type, $filename);
2132
}
2133

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

            
2147
sub read_file {
2148
    my ($path) = @_;
2149
    open my $fh, '<', $path or die "Cannot read $path: $!";
2150
    local $/;
2151
    return <$fh>;
2152
}
2153

            
2154
sub write_file {
2155
    my ($path, $content) = @_;
2156
    open my $fh, '>', $path or die "Cannot write $path: $!";
2157
    print {$fh} $content;
2158
    close $fh or die "Cannot close $path: $!";
2159
}
2160

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

            
Bogdan Timofte authored 2 days ago
2172
sub publish_dns_change {
2173
    my ($registry, $reason) = @_;
2174
    $reason = clean_scalar($reason || 'registry-change');
2175

            
2176
    backup_file($opt{local_hosts_tsv});
2177
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
2178

            
2179
    my $trigger = $opt{dns_publish_trigger} || '';
2180
    return {
2181
        queued => json_bool(0),
2182
        file => $opt{local_hosts_tsv},
2183
        reason => $reason,
2184
    } unless length $trigger;
2185

            
2186
    ensure_parent_dir($trigger);
2187
    open my $fh, '>>', $trigger or die "Cannot write DNS publish trigger $trigger: $!";
2188
    print {$fh} iso_now() . "\t$reason\n";
2189
    close $fh or die "Cannot close DNS publish trigger $trigger: $!";
2190

            
2191
    return {
2192
        queued => json_bool(1),
2193
        file => $opt{local_hosts_tsv},
2194
        trigger => $trigger,
2195
        reason => $reason,
2196
    };
2197
}
2198

            
Bogdan Timofte authored 4 days ago
2199
my $db_handle;
Bogdan Timofte authored 4 days ago
2200
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
2201

            
2202
sub dbh {
2203
    return $db_handle if $db_handle;
2204
    ensure_parent_dir($opt{db});
2205
    $db_handle = DBI->connect(
2206
        "dbi:SQLite:dbname=$opt{db}",
2207
        '',
2208
        '',
2209
        {
2210
            RaiseError => 1,
2211
            PrintError => 0,
2212
            AutoCommit => 1,
2213
            sqlite_unicode => 1,
2214
        },
2215
    ) or die "Cannot open SQLite database $opt{db}\n";
2216
    $db_handle->do('PRAGMA journal_mode = WAL');
2217
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
2218
    create_database_schema($db_handle);
2219
    seed_database($db_handle) unless $db_seeded++;
2220
    return $db_handle;
2221
}
2222

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

            
Bogdan Timofte authored 4 days ago
2498
sub seed_database {
2499
    my ($dbh) = @_;
2500
    seed_default_workers($dbh);
2501

            
2502
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2503
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2504
        normalize_registry_policy($registry);
2505
        with_transaction($dbh, sub {
2506
            import_registry_to_db($dbh, $registry, 0);
2507
        });
2508
    }
2509

            
2510
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2511
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2512
        with_transaction($dbh, sub {
2513
            import_work_orders_to_db($dbh, $orders);
2514
        });
2515
    }
2516

            
2517
    seed_mdns_observations_from_yaml($dbh);
2518
}
2519

            
2520
sub with_transaction {
2521
    my ($dbh, $code) = @_;
2522
    return $code->() unless $dbh->{AutoCommit};
2523
    $dbh->begin_work;
2524
    my $ok = eval {
2525
        $code->();
2526
        1;
2527
    };
2528
    if (!$ok) {
2529
        my $err = $@ || 'transaction failed';
2530
        eval { $dbh->rollback };
2531
        die $err;
2532
    }
2533
    $dbh->commit;
2534
}
2535

            
2536
sub db_scalar {
2537
    my ($dbh, $sql, @bind) = @_;
2538
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2539
    return $value || 0;
2540
}
2541

            
2542
sub legacy_document_text {
2543
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2544
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2545
    return $row->{content} if $row && defined $row->{content};
2546
    return read_file($seed_path) if -f $seed_path;
2547
    return $default_text;
2548
}
2549

            
2550
sub load_registry_from_db {
2551
    my $dbh = dbh();
2552
    my $registry = {
2553
        version => 1,
2554
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2555
        policy => {},
2556
        hosts => [],
2557
    };
Bogdan Timofte authored 4 days ago
2558

            
Bogdan Timofte authored 4 days ago
2559
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2560
    $sth->execute;
2561
    while (my $row = $sth->fetchrow_hashref) {
2562
        my $fqdn = $row->{fqdn};
2563
        push @{ $registry->{hosts} }, {
2564
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2565
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2566
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2567
            ip => canonical_ip($row),
2568
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2569
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2570
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2571
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2572
            monitoring => $row->{monitoring},
2573
            notes => $row->{notes},
2574
        };
2575
    }
2576

            
2577
    return $registry;
Bogdan Timofte authored 4 days ago
2578
}
2579

            
Bogdan Timofte authored 4 days ago
2580
sub save_registry_to_db {
2581
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2582
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2583
    with_transaction($dbh, sub {
2584
        import_registry_to_db($dbh, $registry, 1);
2585
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2586
    });
2587
}
2588

            
2589
sub import_registry_to_db {
2590
    my ($dbh, $registry, $retire_missing) = @_;
2591
    my %seen;
2592
    for my $host (@{ $registry->{hosts} || [] }) {
2593
        my $fqdn = upsert_host_to_db($dbh, $host);
2594
        $seen{$fqdn} = 1 if $fqdn;
2595
    }
2596

            
2597
    return unless $retire_missing;
2598
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2599
    $sth->execute('retired');
2600
    while (my ($fqdn) = $sth->fetchrow_array) {
2601
        next if $seen{$fqdn};
2602
        retire_host_in_db($dbh, $fqdn);
2603
    }
2604
}
2605

            
2606
sub upsert_host_to_db {
2607
    my ($dbh, $host) = @_;
2608
    my $now = iso_now();
2609
    my $fqdn = canonical_host_fqdn($host);
2610
    return '' unless $fqdn;
Bogdan Timofte authored 2 days ago
2611
    my $legacy_id = clean_id($host->{id} || $fqdn);
Bogdan Timofte authored 4 days ago
2612
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2613
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2614
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2615
    my $notes = clean_scalar($host->{notes} || '');
2616

            
Bogdan Timofte authored 4 days ago
2617
    $dbh->do(
Bogdan Timofte authored 4 days ago
2618
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2619
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2620
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2621
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2622
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2623
        undef,
Bogdan Timofte authored 4 days ago
2624
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2625
    );
2626

            
2627
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2628
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2629
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2630
    return $fqdn;
2631
}
2632

            
Bogdan Timofte authored 4 days ago
2633
sub upsert_host_tls_row {
2634
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2635
    $certificate_id = clean_certificate_id($certificate_id || '');
2636
    $dbh->do(
2637
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2638
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2639
        undef,
2640
        $host_fqdn,
2641
        length($certificate_id) ? 'local-ca' : 'none',
2642
        length($certificate_id) ? $certificate_id : undef,
2643
        '',
2644
        $now,
2645
        $now,
2646
    );
2647
}
2648

            
Bogdan Timofte authored 4 days ago
2649
sub sync_host_values {
2650
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2651
    my $now = iso_now();
2652
    my %active = map { $_ => 1 } @$values;
2653
    for my $value (@$values) {
2654
        $dbh->do(
2655
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2656
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2657
            undef,
2658
            $fqdn, $value, $now,
2659
        );
2660
    }
2661

            
2662
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2663
    $sth->execute($fqdn);
2664
    while (my ($value) = $sth->fetchrow_array) {
2665
        next if $active{$value};
2666
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2667
    }
2668
}
2669

            
Bogdan Timofte authored 4 days ago
2670
sub sync_host_aliases_and_vhosts {
2671
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2672
    my $now = iso_now();
2673
    my (%aliases, %vhosts);
2674
    if (my $short = short_alias_for_fqdn($fqdn)) {
2675
        $aliases{$short} = 1;
2676
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2677
    }
Bogdan Timofte authored 4 days ago
2678
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2679
        $name = normalize_dns_name($name);
2680
        next unless length $name;
2681
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2682
        $aliases{$name} = 1;
2683
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2684
        if (my $short = short_alias_for_fqdn($name)) {
2685
            $aliases{$short} = 1;
2686
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2687
        }
2688
    }
2689
    for my $name (@$vhosts_in) {
2690
        $name = normalize_dns_name($name);
2691
        next unless length $name;
2692
        $vhosts{$name} = 1;
2693
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2694
        if (my $short = short_alias_for_fqdn($name)) {
2695
            $aliases{$short} = 1;
2696
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2697
        }
2698
    }
2699

            
2700
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2701
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2702
}
2703

            
2704
sub upsert_alias_to_db {
2705
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2706
    my ($existing_fqdn) = $dbh->selectrow_array(
2707
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2708
        undef,
2709
        $alias,
2710
    );
2711
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2712
        if ($kind eq 'derived-vhost') {
2713
            $dbh->do(
2714
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2715
                undef,
2716
                $now, $alias, $existing_fqdn,
2717
            );
2718
        } else {
2719
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2720
        }
2721
    }
Bogdan Timofte authored 4 days ago
2722
    $dbh->do(
2723
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2724
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2725
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2726
        undef,
2727
        $alias, $fqdn, $kind, $now,
2728
    );
2729
}
2730

            
2731
sub upsert_vhost_to_db {
2732
    my ($dbh, $fqdn, $vhost, $now) = @_;
2733
    my $service = vhost_service_name($vhost);
2734
    $dbh->do(
2735
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2736
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2737
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2738
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2739
        undef,
2740
        $vhost, $fqdn, $service, $now, $now,
2741
    );
2742
}
2743

            
2744
sub retire_missing_names {
2745
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2746
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2747
    $sth->execute($fqdn);
2748
    while (my ($name) = $sth->fetchrow_array) {
2749
        next if $active->{$name};
2750
        if ($table eq 'host_aliases') {
2751
            $dbh->do(
2752
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2753
                undef, $now, $fqdn, $name,
2754
            );
2755
        } else {
2756
            $dbh->do(
2757
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2758
                undef, $now, $fqdn, $name,
2759
            );
2760
        }
2761
    }
2762
}
2763

            
2764
sub retire_host_in_db {
2765
    my ($dbh, $fqdn) = @_;
2766
    my $now = iso_now();
2767
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2768
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2769
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2770
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2771
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2772
}
2773

            
Bogdan Timofte authored 4 days ago
2774
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2775
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2776
    my @names;
Bogdan Timofte authored 4 days ago
2777
    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");
2778
    $aliases->execute($fqdn);
2779
    while (my ($name) = $aliases->fetchrow_array) {
2780
        push @names, $name;
2781
    }
Bogdan Timofte authored 4 days ago
2782
    return unique_preserve(@names);
2783
}
2784

            
2785
sub active_vhosts_for_host {
2786
    my ($dbh, $fqdn) = @_;
2787
    my @names;
Bogdan Timofte authored 4 days ago
2788
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2789
    $vhosts->execute($fqdn);
2790
    while (my ($name) = $vhosts->fetchrow_array) {
2791
        push @names, $name;
2792
    }
2793
    return unique_preserve(@names);
2794
}
2795

            
2796
sub active_values_for_host {
2797
    my ($dbh, $table, $column, $fqdn) = @_;
2798
    my @values;
2799
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2800
    $sth->execute($fqdn);
2801
    while (my ($value) = $sth->fetchrow_array) {
2802
        push @values, $value;
2803
    }
2804
    return @values;
2805
}
2806

            
2807
sub load_work_orders_from_db {
2808
    my $dbh = dbh();
2809
    my $orders = { version => 1, work_orders => [] };
2810
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2811
    $sth->execute;
2812
    while (my $row = $sth->fetchrow_hashref) {
2813
        my $wo = {
2814
            id => $row->{id},
2815
            status => $row->{status},
2816
            title => $row->{title},
2817
            reason => $row->{reason},
2818
            created_at => $row->{created_at},
2819
            checklist => [],
2820
            actions => [],
2821
        };
2822
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2823
        $wo->{result} = $row->{result} if length($row->{result} || '');
2824

            
2825
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2826
        $items->execute($row->{id});
2827
        while (my $item = $items->fetchrow_hashref) {
2828
            my %copy = (
2829
                id => $item->{item_id},
2830
                text => $item->{text},
2831
                status => $item->{status},
2832
            );
2833
            for my $key (qw(owner notes updated_at)) {
2834
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2835
            }
2836
            push @{ $wo->{checklist} }, \%copy;
2837
        }
2838

            
2839
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2840
        $actions->execute($row->{id});
2841
        while (my $action = $actions->fetchrow_hashref) {
2842
            my %copy = ( type => $action->{type} );
2843
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2844
            $copy{name} = $action->{name} if length($action->{name} || '');
2845
            push @{ $wo->{actions} }, \%copy;
2846
        }
2847

            
2848
        push @{ $orders->{work_orders} }, $wo;
2849
    }
2850
    return $orders;
2851
}
2852

            
2853
sub save_work_orders_to_db {
2854
    my ($orders) = @_;
2855
    my $dbh = dbh();
2856
    with_transaction($dbh, sub {
2857
        import_work_orders_to_db($dbh, $orders);
2858
    });
2859
}
2860

            
2861
sub import_work_orders_to_db {
2862
    my ($dbh, $orders) = @_;
2863
    my $now = iso_now();
2864
    my %seen;
2865
    for my $wo (@{ $orders->{work_orders} || [] }) {
2866
        my $id = clean_scalar($wo->{id} || '');
2867
        next unless $id;
2868
        $seen{$id} = 1;
2869
        $dbh->do(
2870
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2871
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2872
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2873
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2874
            undef,
2875
            $id,
2876
            clean_scalar($wo->{status} || 'pending'),
2877
            clean_scalar($wo->{title} || ''),
2878
            clean_scalar($wo->{reason} || ''),
2879
            clean_scalar($wo->{created_at} || $now),
2880
            clean_scalar($wo->{confirmed_at} || ''),
2881
            clean_scalar($wo->{result} || ''),
2882
            $now,
2883
        );
2884
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2885
        for my $item (@{ $wo->{checklist} || [] }) {
2886
            $dbh->do(
2887
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2888
                undef,
2889
                $id,
2890
                clean_scalar($item->{id} || ''),
2891
                clean_scalar($item->{text} || ''),
2892
                clean_scalar($item->{status} || 'pending'),
2893
                clean_scalar($item->{owner} || ''),
2894
                clean_scalar($item->{notes} || ''),
2895
                clean_scalar($item->{updated_at} || ''),
2896
            );
2897
        }
2898
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2899
        my $position = 0;
2900
        for my $action (@{ $wo->{actions} || [] }) {
2901
            my $legacy_id = clean_id($action->{host_id} || '');
2902
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2903
            $dbh->do(
2904
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2905
                undef,
2906
                $id,
2907
                $position++,
2908
                clean_scalar($action->{type} || ''),
2909
                $host_fqdn || undef,
2910
                $legacy_id,
2911
                normalize_dns_name($action->{name} || ''),
2912
                '',
2913
            );
2914
        }
2915
    }
2916
}
2917

            
2918
sub seed_default_workers {
2919
    my ($dbh) = @_;
2920
    my $now = iso_now();
2921
    my @workers = (
2922
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2923
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2924
    );
2925
    for my $worker (@workers) {
2926
        $dbh->do(
2927
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2928
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2929
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2930
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2931
            undef,
2932
            @$worker,
2933
            $now,
2934
            $now,
2935
        );
2936
    }
2937
}
2938

            
2939
sub seed_mdns_observations_from_yaml {
2940
    my ($dbh) = @_;
2941
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2942
    my $path = "$project_dir/var/mdns-observations.yaml";
2943
    return unless -f $path;
2944
    my $db = parse_mdns_observations_yaml(read_file($path));
2945
    with_transaction($dbh, sub {
2946
        for my $observation (@{ $db->{observations} || [] }) {
2947
            $dbh->do(
2948
                '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) '
2949
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2950
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2951
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2952
                undef,
2953
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2954
                clean_scalar($observation->{name} || ''),
2955
                clean_scalar($observation->{ip} || ''),
2956
                int($observation->{ttl} || 0),
2957
                clean_scalar($observation->{first_seen} || iso_now()),
2958
                clean_scalar($observation->{last_seen} || iso_now()),
2959
                int($observation->{seen_count} || 1),
2960
                clean_scalar($observation->{last_peer} || ''),
2961
            );
2962
        }
2963
    });
2964
}
2965

            
2966
sub parse_mdns_observations_yaml {
2967
    my ($text) = @_;
2968
    my %db = ( observations => [] );
2969
    my ($section, $current);
2970
    for my $line (split /\n/, $text || '') {
2971
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2972
        if ($line =~ /^observations:\s*$/) {
2973
            $section = 'observations';
2974
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2975
            $current = { key => yaml_unquote($1) };
2976
            push @{ $db{observations} }, $current;
2977
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2978
            $current->{$1} = yaml_unquote($2);
2979
        }
2980
    }
2981
    return \%db;
2982
}
2983

            
2984
sub set_schema_meta {
2985
    my ($dbh, $key, $value) = @_;
2986
    $dbh->do(
2987
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2988
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2989
        undef,
2990
        $key,
2991
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2992
        iso_now(),
2993
    );
2994
}
2995

            
Bogdan Timofte authored 4 days ago
2996
sub fqdn_for_legacy_id {
2997
    my ($dbh, $legacy_id) = @_;
2998
    return '' unless length($legacy_id || '');
Bogdan Timofte authored 2 days ago
2999
    my ($fqdn) = $dbh->selectrow_array(
3000
        'SELECT fqdn FROM hosts WHERE legacy_id = ? OR fqdn = ?',
3001
        undef,
3002
        $legacy_id,
3003
        $legacy_id,
3004
    );
Bogdan Timofte authored 4 days ago
3005
    return $fqdn || '';
3006
}
3007

            
3008
sub canonical_host_fqdn {
3009
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
3010
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
3011
    return $fqdn if length $fqdn;
3012
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
3013
    for my $name (@names) {
3014
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
3015
    }
3016
    for my $name (@names) {
3017
        return $name if $name =~ /\./ && !name_is_vhost($name);
3018
    }
3019
    my $id = clean_id($host->{id} || '');
3020
    return $id ? "$id.madagascar.xdev.ro" : '';
3021
}
3022

            
3023
sub legacy_id_from_fqdn {
3024
    my ($fqdn) = @_;
3025
    $fqdn = normalize_dns_name($fqdn);
3026
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
3027
    $fqdn =~ s/\..*\z//;
3028
    return clean_id($fqdn);
3029
}
3030

            
3031
sub normalize_dns_name {
3032
    my ($name) = @_;
3033
    $name = lc clean_scalar($name || '');
3034
    $name =~ s/\.\z//;
3035
    return $name;
3036
}
3037

            
3038
sub name_is_vhost {
3039
    my ($name) = @_;
3040
    $name = normalize_dns_name($name);
3041
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
3042
}
3043

            
Bogdan Timofte authored 3 days ago
3044
sub vhost_name_is_valid {
3045
    my ($name) = @_;
3046
    $name = normalize_dns_name($name);
3047
    return 0 unless length $name;
3048
    return 0 unless $name eq 'madagascar.xdev.ro' || $name =~ /\.madagascar\.xdev\.ro\z/;
3049
    return 0 unless length($name) <= 253;
3050
    for my $label (split /\./, $name) {
3051
        return 0 unless length($label) >= 1 && length($label) <= 63;
3052
        return 0 unless $label =~ /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/;
3053
    }
3054
    return 1;
3055
}
3056

            
Bogdan Timofte authored 4 days ago
3057
sub vhost_service_name {
3058
    my ($name) = @_;
3059
    $name = normalize_dns_name($name);
3060
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
3061
    return '';
3062
}
3063

            
3064
sub short_alias_for_fqdn {
3065
    my ($name) = @_;
3066
    $name = normalize_dns_name($name);
3067
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
3068
    return '';
3069
}
3070

            
Bogdan Timofte authored 4 days ago
3071
sub normalize_registry_policy {
3072
    my ($registry) = @_;
3073
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
3074
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
3075
    $registry->{policy}{runtime_database} = $opt{db};
3076
}
3077

            
3078
sub default_hosts_yaml {
3079
    return <<'YAML';
3080
version: 1
3081
updated_at: ""
3082
policy:
Bogdan Timofte authored 4 days ago
3083
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
3084
hosts:
3085
YAML
3086
}
3087

            
3088
sub default_work_orders_yaml {
3089
    return <<'YAML';
3090
version: 1
3091
work_orders:
3092
YAML
3093
}
3094

            
3095
sub ensure_parent_dir {
3096
    my ($path) = @_;
3097
    my $dir = dirname($path);
3098
    make_path($dir) unless -d $dir;
3099
}
3100

            
Xdev Host Manager authored a week ago
3101
sub url_decode {
3102
    my ($value) = @_;
3103
    $value = '' unless defined $value;
3104
    $value =~ tr/+/ /;
3105
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
3106
    return $value;
3107
}
3108

            
3109
sub random_hex {
3110
    my ($bytes) = @_;
3111
    if (open my $fh, '<:raw', '/dev/urandom') {
3112
        read($fh, my $raw, $bytes);
3113
        close $fh;
3114
        return unpack('H*', $raw);
3115
    }
3116
    return sha256_hex(rand() . time() . $$);
3117
}
3118

            
3119
sub iso_now {
3120
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
3121
}
3122

            
Bogdan Timofte authored 6 days ago
3123
sub build_info {
3124
    my %info = (
3125
        revision => '',
3126
        branch => '',
3127
        built_at => '',
3128
        deployed_at => '',
3129
        dirty => '',
3130
    );
3131

            
3132
    if ($ENV{HOST_MANAGER_BUILD}) {
3133
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
3134
        return \%info;
3135
    }
3136

            
3137
    my $build_file = "$project_dir/BUILD";
3138
    if (-f $build_file) {
3139
        for my $line (split /\n/, read_file($build_file)) {
3140
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
3141
            $info{$1} = clean_scalar($2);
3142
        }
3143
        return \%info if $info{revision} || $info{built_at};
3144
    }
3145

            
3146
    my $revision = git_value('rev-parse --short=12 HEAD');
3147
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
3148
    $info{revision} = $revision if $revision;
3149
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
3150
    return \%info;
3151
}
3152

            
3153
sub git_value {
3154
    my ($args) = @_;
3155
    return '' unless -d "$project_dir/.git";
3156
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
3157
    my $value = <$fh> || '';
3158
    close $fh;
3159
    chomp $value;
3160
    return clean_scalar($value);
3161
}
3162

            
3163
sub build_label {
3164
    my $info = build_info();
3165
    my $revision = $info->{revision} || 'unknown';
3166
    my $branch = $info->{branch} || '';
3167
    $branch = '' if $branch eq 'HEAD';
3168
    my $label = $branch ? "$branch $revision" : $revision;
3169
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
3170
    return $label;
3171
}
3172

            
3173
sub build_title {
3174
    my $info = build_info();
3175
    my $label = build_label();
3176
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
3177
    return $stamp ? "$label deployed $stamp" : $label;
3178
}
3179

            
Bogdan Timofte authored 4 days ago
3180
sub build_revision {
3181
    my $info = build_info();
3182
    return $info->{revision} || 'unknown';
3183
}
3184

            
3185
sub build_details {
3186
    my $info = build_info();
3187
    my %details = (
3188
        app => 'Madagascar Local Authority',
3189
        revision => $info->{revision} || 'unknown',
3190
        branch => $info->{branch} || '',
3191
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
3192
        built_at => $info->{built_at} || '',
3193
        deployed_at => $info->{deployed_at} || '',
3194
        label => build_label(),
3195
        title => build_title(),
3196
    );
3197
    return json_encode(\%details);
3198
}
3199

            
Bogdan Timofte authored 6 days ago
3200
sub html_escape {
3201
    my ($value) = @_;
3202
    $value = '' unless defined $value;
3203
    $value =~ s/&/&amp;/g;
3204
    $value =~ s/</&lt;/g;
3205
    $value =~ s/>/&gt;/g;
3206
    $value =~ s/"/&quot;/g;
3207
    $value =~ s/'/&#039;/g;
3208
    return $value;
3209
}
3210

            
Xdev Host Manager authored a week ago
3211
sub app_html {
Bogdan Timofte authored 4 days ago
3212
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
3213
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
3214
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
3215
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
3216
<!doctype html>
3217
<html lang="ro">
3218
<head>
3219
  <meta charset="utf-8">
3220
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
3221
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
3222
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
3223
  <style>
3224
    :root {
3225
      color-scheme: light;
3226
      --ink: #152033;
3227
      --muted: #647084;
3228
      --line: #d8dee8;
3229
      --soft: #f4f6f9;
3230
      --panel: #ffffff;
3231
      --accent: #1267d8;
3232
      --bad: #b42318;
3233
      --warn: #946200;
3234
      --ok: #137333;
3235
    }
3236
    * { box-sizing: border-box; }
3237
    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
3238

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

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

            
Xdev Host Manager authored a week ago
3526
  <!-- ── Login screen ── -->
3527
  <div id="login-screen">
3528
    <div class="login-card">
3529
      <div class="brand">
3530
        <div class="icon">
Xdev Host Manager authored a week ago
3531
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3532
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3533
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3534
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3535
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3536
            <path d="M26 20h8M26 32h8M26 44h8"/>
3537
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3538
          </svg>
3539
        </div>
Xdev Host Manager authored a week ago
3540
        <h1>Madagascar Local Authority</h1>
3541
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3542
      </div>
Bogdan Timofte authored 4 days ago
3543
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3544
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3545
        <div class="pm-helper-fields" aria-hidden="true">
3546
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3547
          <input type="hidden" id="otp-hidden" name="otp">
3548
        </div>
Xdev Host Manager authored a week ago
3549
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3550
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3551
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3552
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3553
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3554
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3555
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3556
        </div>
3557
      </form>
3558
    </div>
3559
  </div>
3560

            
3561
  <!-- ── App (shown after login) ── -->
3562
  <div id="app">
3563
    <header>
Xdev Host Manager authored a week ago
3564
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3565
      <nav aria-label="Sections">
3566
        <a href="/overview" data-page-link="overview">Overview</a>
3567
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3568
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3569
        <a href="/dns" data-page-link="dns">DNS</a>
3570
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3571
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3572
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3573
      </nav>
Xdev Host Manager authored a week ago
3574
      <div class="header-right">
3575
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3576
        <span id="message" class="muted"></span>
3577
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3578
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3579
      </div>
Xdev Host Manager authored a week ago
3580
    </header>
3581
    <main>
Bogdan Timofte authored 5 days ago
3582
      <section class="page" id="page-overview" data-page="overview">
3583
        <section class="panel">
3584
          <div class="panel-head">
3585
            <h2>Overview</h2>
3586
            <div class="stats" id="stats"></div>
3587
          </div>
3588
          <div class="problems" id="problems"></div>
3589
        </section>
Xdev Host Manager authored a week ago
3590
      </section>
3591

            
Bogdan Timofte authored 5 days ago
3592
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3593
        <section class="panel">
3594
          <div class="panel-head">
3595
            <h2>Hosts</h2>
3596
            <div class="host-tools">
3597
              <input id="filter" placeholder="filter">
3598
              <button type="button" id="new-host">New host</button>
3599
            </div>
3600
          </div>
3601
          <div class="table-wrap">
3602
            <table>
3603
              <thead>
3604
                <tr>
Bogdan Timofte authored 3 days ago
3605
                  <th style="width: 280px">Host</th>
Bogdan Timofte authored 4 days ago
3606
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 4 days ago
3607
                  <th>Aliases</th>
Bogdan Timofte authored 5 days ago
3608
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored 4 days ago
3609
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 5 days ago
3610
                  <th style="width: 110px">Monitoring</th>
3611
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3612
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3613
                </tr>
3614
              </thead>
3615
              <tbody id="hosts"></tbody>
3616
            </table>
3617
          </div>
3618
        </section>
Xdev Host Manager authored a week ago
3619
      </section>
Xdev Host Manager authored a week ago
3620

            
Bogdan Timofte authored 4 days ago
3621
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3622
        <section class="panel">
3623
          <div class="panel-head">
3624
            <h2>Vhosts</h2>
3625
            <div class="host-tools">
3626
              <input id="vhost-filter" placeholder="filter">
3627
              <div class="stats" id="vhost-stats"></div>
3628
            </div>
3629
          </div>
Bogdan Timofte authored 4 days ago
3630
          <div class="vhost-inline-editor">
3631
            <input id="vhost-new-name" placeholder="vhost fqdn">
3632
            <select id="vhost-new-host"></select>
3633
            <button type="button" id="vhost-add">Add</button>
3634
          </div>
Bogdan Timofte authored 4 days ago
3635
          <div class="table-wrap">
3636
            <table>
3637
              <thead>
3638
                <tr>
Bogdan Timofte authored 4 days ago
3639
                  <th style="width: 22%">Vhost</th>
Bogdan Timofte authored 3 days ago
3640
                  <th style="width: 28%">Host</th>
3641
                  <th style="width: 34%">Certificate</th>
Bogdan Timofte authored 4 days ago
3642
                  <th style="width: 8%">Monitoring</th>
3643
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
3644
                </tr>
3645
              </thead>
3646
              <tbody id="vhosts"></tbody>
3647
            </table>
3648
          </div>
3649
        </section>
3650
      </section>
3651

            
Bogdan Timofte authored 5 days ago
3652
      <section class="page" id="page-dns" data-page="dns" hidden>
3653
        <section class="toolbar">
3654
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3655
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3656
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3657
          <button id="write-tsv">Write local-hosts.tsv</button>
3658
        </section>
Xdev Host Manager authored a week ago
3659
      </section>
3660

            
Bogdan Timofte authored 5 days ago
3661
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3662
        <section class="panel">
3663
          <div class="panel-head">
3664
            <h2>Work Orders</h2>
3665
            <div class="stats" id="wo-stats"></div>
3666
          </div>
3667
          <div class="problems" id="work-orders"></div>
3668
        </section>
Xdev Host Manager authored a week ago
3669
      </section>
3670

            
Bogdan Timofte authored 5 days ago
3671
      <section class="page" id="page-ca" data-page="ca" hidden>
3672
        <section class="panel">
3673
          <div class="panel-head">
3674
            <h2>Local Certificate Authority</h2>
3675
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3676
          </div>
3677
          <div class="problems" id="ca-status"></div>
3678
        </section>
3679
        <section class="panel">
3680
          <div class="panel-head">
3681
            <h2>Issued Certificates</h2>
3682
            <div class="stats" id="ca-certs-summary"></div>
3683
          </div>
3684
          <div class="table-wrap">
3685
            <table>
3686
              <thead>
3687
                <tr>
3688
                  <th style="width: 150px">Name</th>
3689
                  <th>DNS names</th>
3690
                  <th style="width: 210px">Validity</th>
3691
                  <th style="width: 180px">Serial</th>
3692
                  <th>Fingerprint</th>
3693
                  <th style="width: 110px">Download</th>
3694
                </tr>
3695
              </thead>
3696
              <tbody id="ca-certs"></tbody>
3697
            </table>
3698
          </div>
3699
        </section>
Xdev Host Manager authored a week ago
3700
      </section>
Bogdan Timofte authored 4 days ago
3701

            
3702
      <section class="page" id="page-debug" data-page="debug" hidden>
3703
        <section class="panel">
3704
          <div class="panel-head">
3705
            <h2>Database</h2>
3706
            <div class="stats" id="debug-db-stats"></div>
3707
          </div>
3708
          <div class="toolbar">
3709
            <div class="debug-controls">
3710
              <button type="button" id="debug-db-refresh">Refresh</button>
3711
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3712
            </div>
3713
          </div>
Bogdan Timofte authored 4 days ago
3714
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3715
        </section>
3716
        <section class="debug-section">
3717
          <section class="panel">
3718
            <div class="panel-head">
3719
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3720
              <div class="debug-table-head-actions">
3721
                <div class="stats" id="debug-table-stats"></div>
3722
                <div class="debug-table-exports">
3723
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3724
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3725
                </div>
3726
              </div>
Bogdan Timofte authored 4 days ago
3727
            </div>
3728
            <div class="table-wrap" id="debug-table-rows"></div>
3729
          </section>
3730
          <section class="panel">
3731
            <div class="panel-head">
3732
              <h2>Columns</h2>
3733
            </div>
3734
            <div class="table-wrap" id="debug-table-columns"></div>
3735
          </section>
3736
          <section class="panel">
3737
            <div class="panel-head">
3738
              <h2>Indexes</h2>
3739
            </div>
3740
            <div class="table-wrap" id="debug-table-indexes"></div>
3741
          </section>
3742
          <section class="panel">
3743
            <div class="panel-head">
3744
              <h2>Foreign Keys</h2>
3745
            </div>
3746
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3747
          </section>
3748
        </section>
3749
      </section>
Bogdan Timofte authored 5 days ago
3750
    </main>
Xdev Host Manager authored a week ago
3751

            
3752
  </div>
3753

            
Bogdan Timofte authored 4 days ago
3754
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3755
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3756
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3757
  </div>
Bogdan Timofte authored 6 days ago
3758

            
Xdev Host Manager authored a week ago
3759
  <script>
Bogdan Timofte authored 4 days ago
3760
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3761
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3762
    let hostFormBusy = false;
3763
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3764
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3765

            
3766
    const $ = (id) => document.getElementById(id);
3767
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 4 days ago
3768
    const hostFormShell = document.createElement('div');
3769
    hostFormShell.id = 'host-form-shell';
3770
    hostFormShell.className = 'host-inline-editor-shell';
3771
    hostFormShell.hidden = true;
3772
    hostFormShell.innerHTML = `
3773
      <div class="host-inline-editor-head">
3774
        <h2 id="host-form-title">New host</h2>
3775
        <div class="host-inline-editor-tools">
Bogdan Timofte authored 3 days ago
3776
          <button class="primary" type="submit" id="save-host" form="host-form">Save host</button>
3777
          <button class="danger" type="button" id="delete-host">Delete host</button>
Bogdan Timofte authored 4 days ago
3778
          <button type="button" id="cancel-host-form">Close</button>
3779
        </div>
3780
      </div>
3781
      <form id="host-form" class="grid">
Bogdan Timofte authored 2 days ago
3782
        <input type="hidden" name="id">
Bogdan Timofte authored 4 days ago
3783
        <label>FQDN<input name="fqdn" required></label>
3784
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3785
        <label>IP<input name="ip" required></label>
Bogdan Timofte authored 3 days ago
3786
        <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
3787
        <label>Roles<input name="roles"></label>
3788
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3789
        <label>Notes<input name="notes"></label>
3790
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3791
      </form>`;
3792
    const hostForm = hostFormShell.querySelector('#host-form');
3793
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3794
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3795
    const saveHostButton = hostFormShell.querySelector('#save-host');
3796
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3797
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
Bogdan Timofte authored 3 days ago
3798
    const hostAddAliasEditorButton = hostFormShell.querySelector('#host-add-alias-editor');
Bogdan Timofte authored 4 days ago
3799
    const hostEditorRow = document.createElement('tr');
3800
    hostEditorRow.className = 'host-inline-row';
3801
    const hostEditorCell = document.createElement('td');
Bogdan Timofte authored 3 days ago
3802
    hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
3803
    hostEditorRow.appendChild(hostEditorCell);
3804
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
3805
    const PAGE_PATHS = {
3806
      '/': 'overview',
3807
      '/overview': 'overview',
3808
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3809
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3810
      '/dns': 'dns',
3811
      '/work-orders': 'work-orders',
3812
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3813
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3814
    };
Xdev Host Manager authored a week ago
3815

            
Bogdan Timofte authored 4 days ago
3816
    function isAuthLost(error) {
3817
      return !!(error && error.authLost);
3818
    }
3819

            
3820
    function authLostError(message) {
3821
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3822
      error.authLost = true;
3823
      return error;
3824
    }
3825

            
3826
    function handleAuthLost(message) {
3827
      state.authenticated = false;
3828
      msg('');
3829
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3830
    }
3831

            
Bogdan Timofte authored 4 days ago
3832
    async function ensureAuthenticated(message) {
3833
      if (!state.authenticated) {
3834
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3835
        return false;
3836
      }
3837
      const session = await api('/api/session');
3838
      state.authenticated = session.authenticated;
3839
      if (!state.authenticated) {
3840
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3841
        return false;
3842
      }
3843
      return true;
3844
    }
3845

            
Xdev Host Manager authored a week ago
3846
    async function api(path, options = {}) {
3847
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3848
      let body = {};
3849
      try {
3850
        body = await res.json();
3851
      } catch (_) {
3852
        body = {};
3853
      }
3854
      const errorCode = body.error || '';
3855
      if (!res.ok) {
3856
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3857
          const error = authLostError();
3858
          handleAuthLost(error.message);
3859
          throw error;
3860
        }
Bogdan Timofte authored 3 days ago
3861
        const error = new Error(body.detail || errorCode || res.statusText);
3862
        error.code = errorCode;
3863
        throw error;
Bogdan Timofte authored 4 days ago
3864
      }
Xdev Host Manager authored a week ago
3865
      return body;
3866
    }
3867

            
Bogdan Timofte authored 5 days ago
3868
    function currentPage() {
3869
      return PAGE_PATHS[window.location.pathname] || 'overview';
3870
    }
3871

            
3872
    function showPage(page, push = false) {
3873
      const target = page || 'overview';
3874
      document.querySelectorAll('[data-page]').forEach(section => {
3875
        section.hidden = section.dataset.page !== target;
3876
      });
3877
      document.querySelectorAll('[data-page-link]').forEach(link => {
3878
        link.classList.toggle('active', link.dataset.pageLink === target);
3879
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3880
      });
3881
      if (push) {
3882
        const href = target === 'overview' ? '/overview' : '/' + target;
3883
        history.pushState({ page: target }, '', href);
3884
      }
Bogdan Timofte authored 4 days ago
3885
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3886
        renderDebugDatabase().catch(e => {
3887
          if (!isAuthLost(e)) msg(e.message);
3888
        });
Bogdan Timofte authored 4 days ago
3889
      }
Bogdan Timofte authored 5 days ago
3890
    }
3891

            
Xdev Host Manager authored a week ago
3892
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3893
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3894
      document.body.classList.remove('is-app');
3895
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3896
      $('app').style.display = 'none';
3897
      $('login-screen').style.display = 'flex';
3898
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3899
      clearOtp();
Xdev Host Manager authored a week ago
3900
    }
3901

            
3902
    function showApp() {
Bogdan Timofte authored 6 days ago
3903
      document.body.classList.remove('is-login');
3904
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3905
      $('login-screen').style.display = 'none';
3906
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3907
      showPage(currentPage());
Xdev Host Manager authored a week ago
3908
    }
3909

            
Xdev Host Manager authored a week ago
3910
    async function refresh() {
3911
      const session = await api('/api/session');
3912
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3913
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3914
      showApp();
Xdev Host Manager authored a week ago
3915
      const data = await api('/api/hosts');
3916
      state.hosts = data.hosts || [];
Bogdan Timofte authored 4 days ago
3917
      state.vhosts = data.vhosts || [];
3918
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3919
      state.problems = data.problems || [];
3920
      render(data);
Xdev Host Manager authored a week ago
3921
      await renderCa();
Xdev Host Manager authored a week ago
3922
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3923
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3924
    }
3925

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

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

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

            
3939
      renderHosts();
Bogdan Timofte authored 4 days ago
3940
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3941
      renderVhosts();
Xdev Host Manager authored a week ago
3942
    }
3943

            
Xdev Host Manager authored a week ago
3944
    async function renderCa() {
3945
      try {
3946
        const status = await api('/api/ca/status');
3947
        if (!status.initialized) {
3948
          $('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
3949
          $('ca-certs-summary').innerHTML = '';
3950
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3951
          return;
3952
        }
3953
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 4 days ago
3954
        state.certificates = certs.map(cert => ({
3955
          ...cert,
3956
          id: cert.id || cert.name || '',
3957
          name: cert.name || cert.id || '',
3958
          has_private_key: !!cert.has_private_key
3959
        }));
Bogdan Timofte authored 5 days ago
3960
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3961
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3962
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3963
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3964
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3965
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3966
            <div>
3967
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3968
              <span>${certs.length} issued certificate(s)</span>
3969
            </div>
Xdev Host Manager authored a week ago
3970
          </div>`;
Bogdan Timofte authored 5 days ago
3971
        $('ca-certs-summary').innerHTML = [
3972
          ['issued', certs.length],
3973
          ['expiring', certs.filter(cert => {
3974
            const days = daysUntil(cert.not_after);
3975
            return days !== null && days >= 0 && days <= 30;
3976
          }).length],
3977
          ['expired', certs.filter(cert => {
3978
            const days = daysUntil(cert.not_after);
3979
            return days !== null && days < 0;
3980
          }).length],
3981
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3982
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3983
          const days = daysUntil(cert.not_after);
3984
          const dnsNames = cert.dns_names || [];
3985
          const dnsHtml = dnsNames.length
3986
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3987
            : '<span class="muted">No DNS SANs reported.</span>';
3988
          return `<tr>
3989
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3990
            <td>${dnsHtml}</td>
3991
            <td>
3992
              <div class="ca-detail">
3993
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3994
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3995
              </div>
3996
            </td>
3997
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3998
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 4 days ago
3999
            <td>
4000
              <div class="vhost-cert-links">
4001
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
4002
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
4003
              </div>
4004
            </td>
Bogdan Timofte authored 5 days ago
4005
          </tr>`;
4006
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
4007
      } catch (e) {
Bogdan Timofte authored 4 days ago
4008
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
4009
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
4010
        $('ca-certs-summary').innerHTML = '';
4011
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
4012
      }
4013
    }
4014

            
Bogdan Timofte authored 5 days ago
4015
    function daysUntil(dateText) {
4016
      const time = Date.parse(dateText || '');
4017
      if (!Number.isFinite(time)) return null;
4018
      return Math.ceil((time - Date.now()) / 86400000);
4019
    }
4020

            
4021
    function certStatusClass(days) {
4022
      if (days === null) return '';
4023
      if (days < 0) return 'bad';
4024
      if (days <= 30) return 'warn';
4025
      return 'ok';
4026
    }
4027

            
4028
    function certStatusLabel(days) {
4029
      if (days === null) return 'validity unknown';
4030
      if (days < 0) return 'expired';
4031
      if (days === 0) return 'expires today';
4032
      return `${days}d remaining`;
4033
    }
4034

            
Xdev Host Manager authored a week ago
4035
    async function renderWorkOrders() {
4036
      try {
4037
        const data = await api('/api/work-orders');
4038
        state.workOrders = data.work_orders || [];
4039
        $('wo-stats').innerHTML = [
4040
          ['pending', data.counts.pending],
4041
          ['total', data.counts.work_orders],
4042
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
4043

            
4044
        if (!state.workOrders.length) {
4045
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
4046
          return;
4047
        }
4048

            
4049
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
4050
          const checklist = wo.checklist || [];
4051
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
4052
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
4053
          const checklistHtml = checklist.map(item => {
4054
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
4055
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
4056
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
4057
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
4058
            </label>`;
4059
          }).join('');
Xdev Host Manager authored a week ago
4060
          const actions = (wo.actions || []).map(a => {
4061
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
4062
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
4063
          }).join('');
4064
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
4065
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
4066
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
4067
            : '';
Bogdan Timofte authored 6 days ago
4068
          return `<div class="problem work-order-card">
4069
            <div class="work-order-head">
Xdev Host Manager authored a week ago
4070
              <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
4071
              ${button}
4072
            </div>
Bogdan Timofte authored 6 days ago
4073
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
4074
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
4075
            <div class="work-order-checklist">${checklistHtml}</div>
4076
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
4077
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
4078
          </div>`;
4079
        }).join('');
Xdev Host Manager authored a week ago
4080
        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
4081
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
4082
      } catch (e) {
Bogdan Timofte authored 4 days ago
4083
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
4084
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
4085
      }
4086
    }
4087

            
Bogdan Timofte authored 4 days ago
4088
    async function renderDebugDatabase() {
4089
      if (!state.authenticated) return;
4090
      const data = await api('/api/debug/database/tables');
4091
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
4092
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
4093
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
4094
      $('debug-db-stats').innerHTML = [
4095
        ['tables', data.counts ? data.counts.tables : tables.length],
4096
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
4097
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4098
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
4099
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
4100
      if (selected) {
4101
        await renderDebugTable(selected);
4102
      } else {
4103
        clearDebugTable();
4104
      }
4105
    }
4106

            
Bogdan Timofte authored 4 days ago
4107
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
4108
      $('debug-db-tables').innerHTML = tables.length
4109
        ? tables.map(table => {
4110
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
4111
            const ref = debugTableReference(database, table.name);
4112
            return `<div class="debug-table-card ${active ? 'active' : ''}">
4113
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
4114
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
4115
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
4116
              </button>
Bogdan Timofte authored 4 days ago
4117
              <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
4118
            </div>`;
Bogdan Timofte authored 4 days ago
4119
          }).join('')
4120
        : '<div class="ca-empty muted">No database tables found.</div>';
4121
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4122
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
4123
          if (!isAuthLost(e)) msg(e.message);
4124
        }));
4125
      });
Bogdan Timofte authored 4 days ago
4126
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
4127
        button.addEventListener('click', async () => {
4128
          try {
4129
            await copyText(button.dataset.debugTableRef || '');
4130
            msg('table reference copied');
4131
          } catch (e) {
4132
            msg('copy failed');
4133
          }
4134
        });
4135
      });
4136
    }
4137

            
4138
    function debugTableReference(database, tableName) {
4139
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
4140
    }
4141

            
4142
    async function selectDebugTable(tableName) {
4143
      state.debugTable = tableName || '';
4144
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4145
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
4146
        const card = button.closest('.debug-table-card');
4147
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
4148
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
4149
      });
4150
      if (state.debugTable) await renderDebugTable(state.debugTable);
4151
    }
4152

            
4153
    function clearDebugTable() {
4154
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
4155
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
4156
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4157
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4158
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4159
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
4160
    }
4161

            
4162
    async function renderDebugTable(tableName) {
4163
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
4164
      if (data.error) throw new Error(data.error);
4165
      $('debug-table-stats').innerHTML = [
4166
        ['table', data.table || tableName],
4167
        ['rows', data.row_count || 0],
4168
        ['shown', (data.rows || []).length],
4169
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
4170
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
4171
      renderDebugRows(data);
4172
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
4173
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
4174
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
4175
    }
4176

            
Bogdan Timofte authored 4 days ago
4177
    function updateDebugExportLinks(tableName) {
4178
      const encoded = encodeURIComponent(tableName || '');
4179
      [
4180
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
4181
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
4182
      ].forEach(([id, href]) => {
4183
        const link = $(id);
4184
        const enabled = !!tableName;
4185
        link.href = enabled ? href : '#';
4186
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
4187
      });
4188
    }
4189

            
Bogdan Timofte authored 4 days ago
4190
    function renderDebugRows(data) {
4191
      const rows = data.rows || [];
4192
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
4193
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
4194
    }
4195

            
4196
    function renderDebugObjectTable(rows, preferredKeys) {
4197
      const keys = preferredKeys && preferredKeys.length
4198
        ? preferredKeys
4199
        : Array.from(rows.reduce((set, row) => {
4200
            Object.keys(row || {}).forEach(key => set.add(key));
4201
            return set;
4202
          }, new Set()));
4203
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
4204
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
4205
      const body = rows.length
4206
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
4207
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
4208
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
4209
    }
4210

            
4211
    function debugCell(value) {
4212
      if (value === null || value === undefined) return 'NULL';
4213
      if (Array.isArray(value)) return value.join(', ');
4214
      if (typeof value === 'object') return JSON.stringify(value);
4215
      return String(value);
4216
    }
4217

            
Xdev Host Manager authored a week ago
4218
    async function updateWorkOrderChecklist(id, itemId, checked) {
4219
      try {
4220
        await api('/api/work-orders/checklist', {
4221
          method: 'POST',
4222
          headers: { 'Content-Type': 'application/json' },
4223
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
4224
        });
4225
        msg('work order updated');
4226
        await refresh();
Bogdan Timofte authored 4 days ago
4227
      } catch (e) {
4228
        if (isAuthLost(e)) return;
4229
        msg(e.message);
4230
        await refresh().catch(refreshError => {
4231
          if (!isAuthLost(refreshError)) msg(refreshError.message);
4232
        });
4233
      }
Xdev Host Manager authored a week ago
4234
    }
4235

            
Xdev Host Manager authored a week ago
4236
    async function confirmWorkOrder(id) {
4237
      const typed = prompt(`Type ${id} to confirm this work order`);
4238
      if (typed !== id) return;
4239
      try {
4240
        await api('/api/work-orders/confirm', {
4241
          method: 'POST',
4242
          headers: { 'Content-Type': 'application/json' },
4243
          body: JSON.stringify({ id, confirm: typed })
4244
        });
4245
        msg('work order confirmed; local-hosts.tsv written');
4246
        await refresh();
Bogdan Timofte authored 4 days ago
4247
      } catch (e) {
4248
        if (isAuthLost(e)) return;
4249
        msg(e.message);
4250
      }
Xdev Host Manager authored a week ago
4251
    }
4252

            
Xdev Host Manager authored a week ago
4253
    function renderHosts() {
4254
      const filter = $('filter').value.toLowerCase();
4255
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
4256
        .slice()
Bogdan Timofte authored 4 days ago
4257
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
4258
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
4259
        .map(h => {
4260
          const problems = state.problems.filter(p => p.host_id === h.id);
4261
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 4 days ago
4262
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 3 days ago
4263
            <td><span class="pill canonical" title="${escapeHtml(h.fqdn || '')}">${escapeHtml(h.fqdn || '')}</span></td>
Bogdan Timofte authored 4 days ago
4264
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
4265
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
4266
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored 4 days ago
4267
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
4268
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
4269
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 3 days ago
4270
            <td><div class="host-actions">
4271
              <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 || '')}">
4272
                <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>
4273
              </button>
4274
              <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 || '')}">
4275
                <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>
4276
              </button>
4277
            </div></td>
Xdev Host Manager authored a week ago
4278
          </tr>`;
4279
        }).join('');
Bogdan Timofte authored 4 days ago
4280
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
4281
        editHost(button.dataset.edit).catch(e => {
4282
          if (!isAuthLost(e)) msg(e.message);
4283
        });
4284
      }));
Bogdan Timofte authored 3 days ago
4285
      document.querySelectorAll('[data-host-delete]').forEach(button => button.addEventListener('click', () => {
4286
        deleteHostInline(button.dataset.hostDelete || '').catch(e => {
Bogdan Timofte authored 4 days ago
4287
          if (!isAuthLost(e)) msg(e.message);
4288
        });
4289
      }));
4290
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
4291
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
4292
          if (!isAuthLost(e)) msg(e.message);
4293
        });
4294
      }));
4295
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
4296
        select.addEventListener('change', () => {
4297
          setHostCertificateFromSelect(select).catch(e => {
4298
            if (!isAuthLost(e)) msg(e.message);
4299
            select.value = select.dataset.currentCertificate || '';
4300
          });
4301
        });
4302
      });
4303
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
4304
        button.addEventListener('click', () => {
4305
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
4306
            if (!isAuthLost(e)) msg(e.message);
4307
          });
4308
        });
4309
      });
Bogdan Timofte authored 4 days ago
4310
      mountHostEditor();
Xdev Host Manager authored a week ago
4311
    }
4312

            
Bogdan Timofte authored 4 days ago
4313
    function renderHostAliasCell(host) {
4314
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4315
        <span class="host-alias-label">${escapeHtml(name)}</span>
4316
        <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>
4317
      </span>`).join('');
4318
      return `<div class="host-alias-cell">
Bogdan Timofte authored 3 days ago
4319
        <div class="host-alias-list">${aliases}</div>
Bogdan Timofte authored 4 days ago
4320
      </div>`;
4321
    }
4322

            
4323
    function renderHostCertificateCell(host) {
4324
      const cert = host.certificate || {};
Bogdan Timofte authored 3 days ago
4325
      const certId = host.certificate_id || certificateIdOf(cert) || '';
Bogdan Timofte authored 4 days ago
4326
      const row = hostCertificateRow(host);
4327
      const links = certId ? `<div class="vhost-cert-links">
4328
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4329
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4330
      </div>` : '';
4331
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4332
      return `<div class="vhost-cert">
4333
        <div class="vhost-cert-main">
4334
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
4335
            ${renderCertificateOptions(certId, row)}
4336
          </select>
4337
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4338
        </div>
4339
        <div class="vhost-cert-meta">${links}${validity}</div>
4340
      </div>`;
4341
    }
4342

            
4343
    function hostCertificateRow(host) {
4344
      return {
4345
        host_fqdn: host.fqdn || '',
4346
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4347
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4348
        certificate_id: host.certificate_id || '',
4349
        certificate: host.certificate || null,
4350
      };
Bogdan Timofte authored 4 days ago
4351
    }
4352

            
4353
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4354
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4355
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4356
        vhost,
4357
        host_id: host.id || '',
4358
        host_fqdn: host.fqdn || '',
4359
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4360
        monitoring: host.monitoring || '',
4361
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4362
        certificate_id: '',
4363
        certificate: null,
Bogdan Timofte authored 4 days ago
4364
      })));
4365
    }
4366

            
4367
    function renderVhosts() {
4368
      const input = $('vhost-filter');
4369
      const filter = input ? input.value.toLowerCase() : '';
4370
      const rows = vhostRows()
4371
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
4372
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
4373
      $('vhost-stats').innerHTML = [
4374
        ['shown', rows.length],
4375
        ['total', vhostRows().length],
4376
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4377
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
Bogdan Timofte authored 4 days ago
4378
        <td>${renderVhostNameCell(row)}</td>
Bogdan Timofte authored 4 days ago
4379
        <td>
4380
          <div class="vhost-host">
4381
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
4382
              ${renderVhostHostOptions(row.host_fqdn)}
4383
            </select>
4384
          </div>
4385
        </td>
Bogdan Timofte authored 4 days ago
4386
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
4387
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
4388
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 3 days ago
4389
      </tr>`).join('') : '<tr><td colspan="5" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
4390
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
4391
        select.addEventListener('change', () => {
4392
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
4393
            if (!isAuthLost(e)) msg(e.message);
4394
            select.value = select.dataset.currentHost || '';
4395
          });
Bogdan Timofte authored 4 days ago
4396
        });
Bogdan Timofte authored 4 days ago
4397
      });
Bogdan Timofte authored 4 days ago
4398
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
4399
        button.addEventListener('click', () => {
4400
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
4401
            if (!isAuthLost(e)) msg(e.message);
4402
          });
4403
        });
4404
      });
Bogdan Timofte authored 4 days ago
4405
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
4406
        select.addEventListener('change', () => {
4407
          setVhostCertificateFromSelect(select).catch(e => {
4408
            if (!isAuthLost(e)) msg(e.message);
4409
            select.value = select.dataset.currentCertificate || '';
4410
          });
4411
        });
4412
      });
4413
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
4414
        button.addEventListener('click', () => {
Bogdan Timofte authored 4 days ago
4415
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 4 days ago
4416
            if (!isAuthLost(e)) msg(e.message);
4417
          });
4418
        });
4419
      });
4420
    }
4421

            
Bogdan Timofte authored 4 days ago
4422
    function renderVhostNameCell(row) {
4423
      const aliases = (row.derived_aliases || []).map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('');
4424
      return `<div class="vhost-name-cell">
4425
        <div class="vhost-name-main">
4426
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
4427
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
4428
        </div>
4429
        ${aliases ? `<div class="vhost-pill-row">${aliases}</div>` : ''}
4430
      </div>`;
4431
    }
4432

            
Bogdan Timofte authored 4 days ago
4433
    function renderVhostCertificateCell(row) {
4434
      const cert = row.certificate || {};
4435
      const certId = row.certificate_id || cert.id || cert.name || '';
4436
      const links = certId ? `<div class="vhost-cert-links">
4437
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
4438
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
4439
      </div>` : '';
4440
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
4441
      return `<div class="vhost-cert">
4442
        <div class="vhost-cert-main">
4443
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 4 days ago
4444
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 4 days ago
4445
          </select>
4446
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
4447
        </div>
4448
        <div class="vhost-cert-meta">${links}${validity}</div>
4449
      </div>`;
Bogdan Timofte authored 4 days ago
4450
    }
4451

            
4452
    function renderVhostEditor() {
4453
      const select = $('vhost-new-host');
4454
      const current = select.value || '';
4455
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4456
    }
4457

            
4458
    function renderVhostHostOptions(selectedHostFqdn) {
4459
      return state.hosts
4460
        .slice()
4461
        .filter(host => (host.status || '') !== 'retired')
4462
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4463
        .map(host => {
4464
          const fqdn = host.fqdn || '';
4465
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4466
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4467
        }).join('');
Bogdan Timofte authored 4 days ago
4468
    }
4469

            
Bogdan Timofte authored 4 days ago
4470
    function renderCertificateOptions(selectedCertificateId, row) {
4471
      const byId = new Map();
4472
      (state.certificates || []).forEach(cert => {
Bogdan Timofte authored 3 days ago
4473
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4474
        if (id) byId.set(id, cert);
4475
      });
4476
      if (row && row.certificate) {
Bogdan Timofte authored 3 days ago
4477
        const id = certificateIdOf(row.certificate);
Bogdan Timofte authored 4 days ago
4478
        if (id && !byId.has(id)) byId.set(id, row.certificate);
4479
      }
4480
      const certs = Array.from(byId.values())
Bogdan Timofte authored 3 days ago
4481
        .filter(cert => certMatchesRow(cert, row) || certificateIdOf(cert) === selectedCertificateId)
Bogdan Timofte authored 4 days ago
4482
        .sort((a, b) => {
4483
          const ar = certRelevance(a, row);
4484
          const br = certRelevance(b, row);
4485
          if (ar !== br) return ar - br;
4486
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
4487
        });
Bogdan Timofte authored 4 days ago
4488
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 3 days ago
4489
        const id = certificateIdOf(cert);
Bogdan Timofte authored 4 days ago
4490
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 4 days ago
4491
        const selected = id === selectedCertificateId ? ' selected' : '';
4492
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
4493
      }));
4494
      return options.join('');
4495
    }
4496

            
Bogdan Timofte authored 3 days ago
4497
    function certificateIdOf(cert) {
Bogdan Timofte authored 4 days ago
4498
      return cert ? (cert.id || cert.name || '') : '';
4499
    }
4500

            
4501
    function certDnsNames(cert) {
4502
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4503
        .map(name => String(name || '').toLowerCase())
4504
        .filter(Boolean);
4505
    }
4506

            
4507
    function certRelevance(cert, row) {
4508
      if (!row) return 9;
4509
      const names = new Set(certDnsNames(cert));
Bogdan Timofte authored 3 days ago
4510
      const id = String(certificateIdOf(cert)).toLowerCase();
Bogdan Timofte authored 4 days ago
4511
      const commonName = String(cert.common_name || '').toLowerCase();
4512
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4513
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 4 days ago
4514
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 4 days ago
4515
      const aliasNames = []
4516
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
4517
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
4518
        .map(name => String(name || '').toLowerCase())
4519
        .filter(Boolean);
4520
      if (vhost) {
4521
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
4522
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
4523
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
4524
        return 9;
4525
      }
4526
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
4527
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 4 days ago
4528
      return 9;
4529
    }
4530

            
4531
    function certMatchesRow(cert, row) {
4532
      return certRelevance(cert, row) < 9;
4533
    }
4534

            
4535
    function compactCertificateLabel(cert, row) {
4536
      const relevance = certRelevance(cert, row);
4537
      const days = daysUntil(cert.not_after);
4538
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 3 days ago
4539
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 4 days ago
4540
      if (row && row.vhost) {
Bogdan Timofte authored 3 days ago
4541
        if (relevance === 0) return `${name}${suffix}`;
4542
        if (relevance === 1) return `host ${name}${suffix}`;
4543
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4544
      } else {
Bogdan Timofte authored 3 days ago
4545
        if (relevance === 0) return `${name}${suffix}`;
4546
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4547
      }
Bogdan Timofte authored 4 days ago
4548
      return `${shortCertificateName(cert)}${suffix}`;
4549
    }
4550

            
Bogdan Timofte authored 3 days ago
4551
    function certificateDisplayName(cert) {
4552
      const commonName = String(cert.common_name || '').trim();
4553
      if (commonName) return commonName;
4554
      const dnsNames = certDnsNames(cert);
4555
      if (dnsNames.length) return dnsNames[0];
4556
      return shortCertificateName(cert);
4557
    }
4558

            
Bogdan Timofte authored 4 days ago
4559
    function shortCertificateName(cert) {
4560
      const name = String(cert.common_name || cert.name || cert.id || '');
4561
      const suffix = '.madagascar.xdev.ro';
4562
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4563
    }
4564

            
Bogdan Timofte authored 4 days ago
4565
    function shortAliasForFqdn(name) {
4566
      const suffix = '.madagascar.xdev.ro';
4567
      name = String(name || '').toLowerCase();
4568
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
4569
    }
4570

            
Bogdan Timofte authored 2 days ago
4571
    function normalizeHostFqdn(name) {
4572
      return String(name || '').trim().toLowerCase().replace(/\.$/, '');
4573
    }
4574

            
4575
    function syncHostFormId() {
4576
      if (!hostField('id').value) {
4577
        hostField('id').value = normalizeHostFqdn(hostField('fqdn').value || '');
4578
      }
4579
    }
4580

            
Bogdan Timofte authored 4 days ago
4581
    function hostByFqdn(fqdn) {
4582
      fqdn = String(fqdn || '').toLowerCase();
4583
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4584
    }
4585

            
4586
    function hostUpsertPayload(host, overrides = {}) {
4587
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4588
      const payload = {
4589
        id: host.id || '',
4590
        fqdn: host.fqdn || '',
4591
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4592
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4593
        aliases,
4594
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
Bogdan Timofte authored 3 days ago
4595
        sources: [],
Bogdan Timofte authored 4 days ago
4596
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4597
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4598
      };
4599
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4600
      return payload;
4601
    }
4602

            
Bogdan Timofte authored 3 days ago
4603
    function aliasEditorValues() {
4604
      return (hostField('aliases').value || '')
4605
        .split(/[\s,]+/)
4606
        .map(value => String(value || '').trim().toLowerCase())
4607
        .filter(Boolean);
4608
    }
4609

            
4610
    function appendAliasInEditor() {
4611
      const fqdn = String(hostField('fqdn').value || '').trim().toLowerCase();
4612
      const derived = shortAliasForFqdn(fqdn);
4613
      const alias = String(prompt(fqdn ? `Alias nou pentru ${fqdn}` : 'Alias nou', '') || '').trim().toLowerCase();
4614
      if (!alias) return;
4615
      if (fqdn && alias === fqdn) {
4616
        msg('fqdn-ul hostului este deja numele principal');
4617
        return;
4618
      }
4619
      if (derived && alias === derived) {
4620
        msg('aliasul derivat din fqdn se genereaza automat');
4621
        return;
4622
      }
4623
      const aliases = aliasEditorValues();
4624
      if (aliases.includes(alias)) {
4625
        msg(`aliasul ${alias} este deja in editor`);
4626
        return;
4627
      }
4628
      hostField('aliases').value = aliases.concat(alias).join('\n');
4629
      hostField('aliases').dispatchEvent(new Event('input', { bubbles: true }));
4630
      hostField('aliases').focus();
4631
    }
4632

            
Bogdan Timofte authored 4 days ago
4633
    async function addHostAlias(hostFqdn) {
4634
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4635
      const host = hostByFqdn(hostFqdn);
4636
      if (!host) return;
4637
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4638
      if (!alias) return;
4639
      if (alias === String(host.fqdn || '').toLowerCase()) {
4640
        msg('fqdn-ul hostului este deja prezent');
4641
        return;
4642
      }
4643
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4644
      await api('/api/hosts/upsert', {
4645
        method: 'POST',
4646
        headers: { 'Content-Type': 'application/json' },
4647
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4648
      });
4649
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4650
      await refresh();
4651
    }
4652

            
4653
    async function removeHostAlias(hostFqdn, alias) {
4654
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4655
      const host = hostByFqdn(hostFqdn);
4656
      alias = String(alias || '').trim().toLowerCase();
4657
      if (!host || !alias) return;
4658
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4659
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4660
      await api('/api/hosts/upsert', {
4661
        method: 'POST',
4662
        headers: { 'Content-Type': 'application/json' },
4663
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4664
      });
4665
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4666
      await refresh();
4667
    }
4668

            
Bogdan Timofte authored 3 days ago
4669
    async function deleteHostInline(id) {
4670
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4671
      const host = state.hosts.find(entry => String(entry.id || '') === String(id || ''));
4672
      if (!host) return;
4673
      if (!confirm(`Delete ${host.fqdn || host.id || id}?`)) return;
4674
      await api('/api/hosts/delete', {
4675
        method: 'POST',
4676
        headers: { 'Content-Type': 'application/json' },
4677
        body: JSON.stringify({ id: host.id || id }),
4678
      });
4679
      if (hostEditorTarget === String(host.id || '')) closeHostForm(true);
4680
      msg(`host ${host.fqdn || host.id || id} deleted`);
4681
      await refresh();
4682
    }
4683

            
Bogdan Timofte authored 4 days ago
4684
    async function setHostCertificateFromSelect(select) {
4685
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4686
        select.value = select.dataset.currentCertificate || '';
4687
        return;
4688
      }
4689
      const hostFqdn = select.dataset.hostCertSelect || '';
4690
      const certificateId = select.value || '';
4691
      const current = select.dataset.currentCertificate || '';
4692
      if (!hostFqdn || certificateId === current) return;
4693
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4694
        select.value = current;
4695
        return;
4696
      }
4697
      select.disabled = true;
4698
      try {
4699
        await api('/api/hosts/certificate', {
4700
          method: 'POST',
4701
          headers: { 'Content-Type': 'application/json' },
4702
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4703
        });
4704
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4705
        await refresh();
4706
      } finally {
4707
        select.disabled = false;
4708
      }
4709
    }
4710

            
4711
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4712
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4713
      if (!hostFqdn) return;
4714
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4715
      if (button) button.disabled = true;
4716
      try {
4717
        const result = await api('/api/hosts/issue-certificate', {
4718
          method: 'POST',
4719
          headers: { 'Content-Type': 'application/json' },
4720
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4721
        });
4722
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4723
        await refresh();
4724
      } finally {
4725
        if (button) button.disabled = false;
4726
      }
4727
    }
4728

            
Bogdan Timofte authored 4 days ago
4729
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4730
      const vhost = select.dataset.vhostSelect || '';
4731
      const fromHost = select.dataset.currentHost || '';
4732
      const toHost = select.value || '';
4733
      if (!vhost || !toHost || toHost === fromHost) return;
4734
      select.disabled = true;
4735
      try {
4736
        await api('/api/vhosts/reassign', {
4737
          method: 'POST',
4738
          headers: { 'Content-Type': 'application/json' },
4739
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4740
        });
4741
        msg(`vhost ${vhost} moved`);
4742
        await refresh();
4743
      } finally {
4744
        select.disabled = false;
4745
      }
4746
    }
4747

            
Bogdan Timofte authored 4 days ago
4748
    async function addVhostInline() {
4749
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4750
      const nameInput = $('vhost-new-name');
4751
      const hostSelect = $('vhost-new-host');
4752
      const vhost = (nameInput.value || '').trim().toLowerCase();
4753
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 3 days ago
4754
      if (!vhost || !hostFqdn) {
4755
        msg('completeaza vhost si host');
4756
        return;
4757
      }
4758
      if (!isValidVhostName(vhost)) {
4759
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4760
        nameInput.focus();
4761
        return;
4762
      }
4763
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4764
        msg('vhost invalid: numele este deja host real');
4765
        nameInput.focus();
4766
        return;
4767
      }
Bogdan Timofte authored 4 days ago
4768
      $('vhost-add').disabled = true;
4769
      nameInput.disabled = true;
4770
      hostSelect.disabled = true;
4771
      try {
4772
        await api('/api/vhosts/upsert', {
4773
          method: 'POST',
4774
          headers: { 'Content-Type': 'application/json' },
4775
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4776
        });
4777
        nameInput.value = '';
4778
        msg(`vhost ${vhost} saved`);
4779
        await refresh();
Bogdan Timofte authored 3 days ago
4780
      } catch (e) {
4781
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4782
      } finally {
4783
        $('vhost-add').disabled = false;
4784
        nameInput.disabled = false;
4785
        hostSelect.disabled = false;
4786
      }
4787
    }
4788

            
Bogdan Timofte authored 3 days ago
4789
    function isValidVhostName(name) {
4790
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4791
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4792
      if (name.length > 253) return false;
4793
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4794
    }
4795

            
4796
    function vhostErrorMessage(error) {
4797
      const code = error && error.code ? error.code : '';
4798
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4799
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4800
      if (code === 'invalid_target_host') return 'host tinta invalid';
4801
      if (code === 'missing_target_host') return 'alege hostul tinta';
4802
      return error && error.message ? error.message : 'vhost add failed';
4803
    }
4804

            
Bogdan Timofte authored 4 days ago
4805
    async function setVhostCertificateFromSelect(select) {
4806
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4807
        select.value = select.dataset.currentCertificate || '';
4808
        return;
4809
      }
4810
      const vhost = select.dataset.vhostCertSelect || '';
4811
      const certificateId = select.value || '';
4812
      const current = select.dataset.currentCertificate || '';
4813
      if (!vhost || certificateId === current) return;
4814
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4815
        select.value = current;
4816
        return;
4817
      }
4818
      select.disabled = true;
4819
      try {
4820
        await api('/api/vhosts/certificate', {
4821
          method: 'POST',
4822
          headers: { 'Content-Type': 'application/json' },
4823
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4824
        });
4825
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4826
        await refresh();
4827
      } finally {
4828
        select.disabled = false;
4829
      }
4830
    }
4831

            
Bogdan Timofte authored 4 days ago
4832
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4833
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4834
      if (!vhost) return;
4835
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4836
      if (button) button.disabled = true;
4837
      try {
4838
        const result = await api('/api/vhosts/issue-certificate', {
4839
          method: 'POST',
4840
          headers: { 'Content-Type': 'application/json' },
4841
          body: JSON.stringify({ vhost_fqdn: vhost }),
4842
        });
4843
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4844
        await refresh();
4845
      } finally {
4846
        if (button) button.disabled = false;
4847
      }
4848
    }
4849

            
Bogdan Timofte authored 4 days ago
4850
    async function deleteVhostInline(vhost) {
4851
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4852
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4853
      await api('/api/vhosts/delete', {
4854
        method: 'POST',
4855
        headers: { 'Content-Type': 'application/json' },
4856
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4857
      });
4858
      msg(`vhost ${vhost} deleted`);
4859
      await refresh();
4860
    }
4861

            
Bogdan Timofte authored 4 days ago
4862
    async function editHost(id) {
4863
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4864
      const host = state.hosts.find(h => h.id === id);
4865
      if (!host) return;
Bogdan Timofte authored 4 days ago
4866
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4867
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4868
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4869
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4870
      hostField('roles').value = (host.roles || []).join(' ');
Bogdan Timofte authored 4 days ago
4871
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4872
    }
4873

            
Bogdan Timofte authored 4 days ago
4874
    async function newHost() {
4875
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4876
      if (!canSwitchHostEditor('__new__')) return;
4877
      resetHostForm(true);
Bogdan Timofte authored 2 days ago
4878
      hostField('id').value = '';
4879
      activateHostForm('New host', 'new', '__new__', 'fqdn');
Bogdan Timofte authored 5 days ago
4880
    }
4881

            
Bogdan Timofte authored 2 days ago
4882
    function activateHostForm(title, mode, target, focusField = 'fqdn', scroll = true) {
Bogdan Timofte authored 4 days ago
4883
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4884
      hostEditorTarget = target || '';
4885
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4886
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4887
      renderHosts();
4888
      hostFormSnapshot = hostFormState();
4889
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4890
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4891
    }
4892

            
Bogdan Timofte authored 4 days ago
4893
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4894
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4895
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4896
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4897
      hostField('status').value = 'active';
4898
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4899
      hostFormSnapshot = force ? '' : hostFormState();
4900
    }
4901

            
4902
    function closeHostForm(force = false) {
4903
      if (hostFormBusy && !force) return;
4904
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4905
      hostEditorTarget = '';
4906
      hostFormMode = 'new';
4907
      hostFormSnapshot = '';
4908
      clearHostFormMessage();
4909
      syncHostFormActions();
4910
      mountHostEditor();
4911
    }
4912

            
4913
    function canSwitchHostEditor(target) {
4914
      if (hostFormBusy) return false;
4915
      if (!hostEditorTarget) return true;
4916
      if (!hostFormDirty()) return true;
4917
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4918
      return confirm('Discard unsaved host changes?');
4919
    }
4920

            
4921
    function mountHostEditor() {
4922
      hostEditorRow.remove();
4923
      if (!hostEditorTarget) {
4924
        hostFormShell.hidden = true;
4925
        return;
4926
      }
Bogdan Timofte authored 3 days ago
4927
      hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
4928
      const tbody = $('hosts');
4929
      if (!tbody) return;
4930
      if (hostEditorTarget === '__new__') {
4931
        tbody.prepend(hostEditorRow);
4932
      } else {
4933
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4934
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4935
        if (targetRow) targetRow.after(hostEditorRow);
4936
        else tbody.prepend(hostEditorRow);
4937
      }
4938
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4939
    }
4940

            
4941
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4942
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4943
    }
4944

            
4945
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4946
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4947
    }
4948

            
4949
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4950
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4951
    }
4952

            
4953
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4954
      hostFormBusy = !!busy;
4955
      syncHostFormActions();
4956
    }
4957

            
4958
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4959
      saveHostButton.disabled = hostFormBusy;
4960
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4961
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
4962
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4963
    }
4964

            
4965
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4966
      hostFormMessage.textContent = text || '';
4967
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4968
    }
4969

            
4970
    function clearHostFormMessage() {
4971
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4972
    }
4973

            
4974
    function formObject(form) {
4975
      return Object.fromEntries(new FormData(form).entries());
4976
    }
4977

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

            
Bogdan Timofte authored 6 days ago
4983
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4984

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

            
4990
    if (loginAccount) {
4991
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4992
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4993
      loginAccount.addEventListener('input', () => {
4994
        const value = (loginAccount.value || '').trim();
4995
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4996
      });
4997
    }
4998

            
Xdev Host Manager authored a week ago
4999
    function setOtpDigit(idx, value) {
5000
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
5001
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
5002
      otpDigits[idx].classList.toggle('filled', !!digit);
5003
    }
5004

            
Bogdan Timofte authored 4 days ago
5005
    // Move focus to the next empty box: forward from idx, then wrapping to the
5006
    // start. This lets out-of-order entry continue (e.g. after the last box,
5007
    // jump back to the first still-empty box). Stays put when all boxes are full.
5008
    function advanceFocus(idx) {
5009
      for (let i = idx + 1; i < otpDigits.length; i++) {
5010
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
5011
      }
5012
      for (let i = 0; i <= idx; i++) {
5013
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
5014
      }
5015
    }
5016

            
Bogdan Timofte authored 4 days ago
5017
    // Spread multiple digits across boxes starting at startIdx. Used for paste
5018
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
5019
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
5020
      const digits = (text || '').replace(/\D/g, '').split('');
5021
      if (!digits.length) return;
5022
      let last = startIdx;
5023
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
5024
        last = startIdx + i;
5025
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
5026
      }
Bogdan Timofte authored 4 days ago
5027
      syncOtpFields();
Bogdan Timofte authored 4 days ago
5028
      advanceFocus(last);
Xdev Host Manager authored a week ago
5029
      maybeSubmitOtp();
5030
    }
5031

            
Bogdan Timofte authored 4 days ago
5032
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
5033
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
5034
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
5035
    function maybeSubmitOtp() {
5036
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
5037
    }
5038
    function clearOtp() {
Bogdan Timofte authored 4 days ago
5039
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
5040
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
5041
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
5042
      // an unknown operator, so Safari's autofill anchor on the username stays.
5043
      if (loginAccount && !loginAccount.value) loginAccount.focus();
5044
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
5045
    }
5046

            
Bogdan Timofte authored 4 days ago
5047
    otpDigits.forEach((input, idx) => {
5048
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
5049
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5050
        // A single box may receive several digits at once (autofill / typing fast).
5051
        if (input.value.replace(/\D/g, '').length > 1) {
5052
          fillOtp(input.value, idx);
5053
          return;
5054
        }
5055
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
5056
        syncOtpFields();
Bogdan Timofte authored 4 days ago
5057
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
5058
        maybeSubmitOtp();
5059
      });
Bogdan Timofte authored 4 days ago
5060

            
5061
      input.addEventListener('paste', (e) => {
5062
        e.preventDefault();
Bogdan Timofte authored 4 days ago
5063
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5064
        const text = (e.clipboardData || window.clipboardData).getData('text');
5065
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
5066
      });
Bogdan Timofte authored 4 days ago
5067

            
5068
      input.addEventListener('keydown', (e) => {
5069
        if (e.key === 'Backspace') {
5070
          e.preventDefault();
Bogdan Timofte authored 4 days ago
5071
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5072
          if (input.value) { setOtpDigit(idx, ''); }
5073
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
5074
          syncOtpFields();
5075
        } else if (e.key === 'ArrowLeft' && idx > 0) {
5076
          e.preventDefault();
5077
          otpDigits[idx - 1].focus();
5078
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
5079
          e.preventDefault();
5080
          otpDigits[idx + 1].focus();
5081
        }
5082
      });
5083
    });
5084

            
Bogdan Timofte authored 4 days ago
5085
    // Focus the first OTP box only for a returning operator (username known).
5086
    // For an unknown operator, leave focus on the username field so Safari can
5087
    // present its OTP autofill anchored there without being dismissed by a focus
5088
    // change (pbx-admin pattern).
5089
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
5090
    else if (loginAccount) loginAccount.focus();
5091
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
5092

            
Bogdan Timofte authored 5 days ago
5093
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
5094
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
5095
        event.preventDefault();
Bogdan Timofte authored 4 days ago
5096
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
5097
        showPage(link.dataset.pageLink, true);
5098
      });
5099
    });
5100

            
Bogdan Timofte authored 4 days ago
5101
    window.addEventListener('popstate', () => {
5102
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
5103
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
5104
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
5105
    });
Bogdan Timofte authored 5 days ago
5106

            
Bogdan Timofte authored 4 days ago
5107
    async function copyText(text) {
5108
      if (navigator.clipboard && window.isSecureContext) {
5109
        await navigator.clipboard.writeText(text);
5110
        return;
5111
      }
5112
      const input = document.createElement('textarea');
5113
      input.value = text;
5114
      input.setAttribute('readonly', '');
5115
      input.style.position = 'fixed';
5116
      input.style.left = '-10000px';
5117
      document.body.appendChild(input);
5118
      input.select();
5119
      document.execCommand('copy');
5120
      document.body.removeChild(input);
5121
    }
5122

            
5123
    $('copy-build').addEventListener('click', async () => {
5124
      try {
5125
        await copyText($('copy-build').dataset.buildDetails || '');
5126
        if (state.authenticated) msg('build details copied');
5127
      } catch (e) {
5128
        if (state.authenticated) msg('copy failed');
5129
      }
5130
    });
5131

            
Xdev Host Manager authored a week ago
5132
    $('login-form').addEventListener('submit', async (event) => {
5133
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5134
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
5135
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
5136
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
5137
      try {
Xdev Host Manager authored a week ago
5138
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
5139
        await refresh();
Xdev Host Manager authored a week ago
5140
      } catch (e) {
5141
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
5142
      } finally {
Xdev Host Manager authored a week ago
5143
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
5144
      }
Xdev Host Manager authored a week ago
5145
    });
5146

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

            
Bogdan Timofte authored 4 days ago
5152
    $('refresh').addEventListener('click', () => refresh().catch(e => {
5153
      if (!isAuthLost(e)) msg(e.message);
5154
    }));
Xdev Host Manager authored a week ago
5155
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
5156
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
5157
    $('vhost-add').addEventListener('click', () => {
5158
      addVhostInline().catch(e => {
5159
        if (!isAuthLost(e)) msg(e.message);
5160
      });
5161
    });
5162
    $('vhost-new-name').addEventListener('keydown', (event) => {
5163
      if (event.key !== 'Enter') return;
5164
      event.preventDefault();
5165
      addVhostInline().catch(e => {
5166
        if (!isAuthLost(e)) msg(e.message);
5167
      });
5168
    });
Bogdan Timofte authored 4 days ago
5169
    $('new-host').addEventListener('click', () => {
5170
      newHost().catch(e => {
5171
        if (!isAuthLost(e)) msg(e.message);
5172
      });
5173
    });
Bogdan Timofte authored 4 days ago
5174
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
5175
      if (!isAuthLost(e)) msg(e.message);
5176
    }));
Bogdan Timofte authored 3 days ago
5177
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 4 days ago
5178
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
5179

            
Bogdan Timofte authored 4 days ago
5180
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
5181
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5182
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 2 days ago
5183
      syncHostFormId();
Bogdan Timofte authored 5 days ago
5184
      setHostFormBusy(true);
5185
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
5186
      try {
5187
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
5188
        msg('host saved');
5189
        await refresh();
Bogdan Timofte authored 3 days ago
5190
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5191
      } catch (e) {
Bogdan Timofte authored 4 days ago
5192
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5193
        setHostFormMessage(e.message, true);
5194
        msg(e.message);
5195
      } finally {
5196
        setHostFormBusy(false);
5197
      }
5198
    });
5199

            
Bogdan Timofte authored 4 days ago
5200
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
5201
      setHostFormMessage('Complete the required host fields before saving.', true);
5202
    }, true);
5203

            
Bogdan Timofte authored 2 days ago
5204
    hostForm.addEventListener('input', (event) => {
5205
      if (hostFormMode === 'new' && event.target && event.target.name === 'fqdn') syncHostFormId();
Bogdan Timofte authored 4 days ago
5206
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
5207
    });
5208

            
Bogdan Timofte authored 4 days ago
5209
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 2 days ago
5210
      const id = hostField('id').value || normalizeHostFqdn(hostField('fqdn').value || '');
Xdev Host Manager authored a week ago
5211
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
5212
      setHostFormBusy(true);
5213
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
5214
      try {
5215
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
5216
        msg('host deleted');
5217
        await refresh();
Bogdan Timofte authored 4 days ago
5218
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5219
      } catch (e) {
Bogdan Timofte authored 4 days ago
5220
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5221
        setHostFormMessage(e.message, true);
5222
        msg(e.message);
5223
      } finally {
5224
        setHostFormBusy(false);
5225
      }
Xdev Host Manager authored a week ago
5226
    });
5227

            
Bogdan Timofte authored 4 days ago
5228
    resetHostForm(true);
5229
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
5230

            
Xdev Host Manager authored a week ago
5231
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
5232
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
5233
      try {
5234
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
5235
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
5236
      } catch (e) {
5237
        if (!isAuthLost(e)) msg(e.message);
5238
      }
Xdev Host Manager authored a week ago
5239
    });
5240

            
Bogdan Timofte authored 4 days ago
5241
    refresh().catch(e => {
5242
      if (!isAuthLost(e)) showLogin(e.message);
5243
    });
Xdev Host Manager authored a week ago
5244
  </script>
5245
</body>
5246
</html>
5247
HTML
Bogdan Timofte authored 6 days ago
5248
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
5249
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
5250
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
5251
    return $html;
Xdev Host Manager authored a week ago
5252
}