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

            
6
use strict;
7
use warnings;
8

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

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

            
21
my %opt = (
22
    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
23
    port => $ENV{HOST_MANAGER_PORT} || 8088,
Bogdan Timofte authored 4 days ago
24
    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
Xdev Host Manager authored a week ago
25
    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
26
    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
Bogdan Timofte authored 3 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 4 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 4 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 4 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 3 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 4 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 3 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 3 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 3 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 3 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) = @_;
736
    my $id = clean_id($payload->{id} || '');
737
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
738

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

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

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

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

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

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

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

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

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

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

            
829
            upsert_host_to_db($dbh, $target_host) if $target_host;
830
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
831
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
832
        });
833
        1;
834
    };
835
    if (!$result) {
836
        my $err = $@ || 'vhost_reassign_failed';
837
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
838
    }
Bogdan Timofte authored 3 days ago
839
    my $publish = publish_dns_change(load_registry(), 'vhost-reassign');
840
    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
841
}
842

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

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

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

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

            
868
            upsert_host_to_db($dbh, $target_host) if $target_host;
869
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
870
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
871
        });
872
        1;
873
    };
874
    if (!$result) {
875
        my $err = $@ || 'vhost_upsert_failed';
876
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
877
    }
Bogdan Timofte authored 3 days ago
878
    my $publish = publish_dns_change(load_registry(), 'vhost-upsert');
879
    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
880
}
881

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

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

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

            
906
            my $registry = load_registry_from_db();
907
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
908
            upsert_host_to_db($dbh, $current_host) if $current_host;
909
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
910
        });
911
        1;
912
    };
913
    if (!$result) {
914
        my $err = $@ || 'vhost_delete_failed';
915
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
916
    }
Bogdan Timofte authored 3 days ago
917
    my $publish = publish_dns_change(load_registry(), 'vhost-delete');
918
    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
919
}
920

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2174
    backup_file($opt{local_hosts_tsv});
2175
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
2176

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

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

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

            
Bogdan Timofte authored 4 days ago
2197
my $db_handle;
Bogdan Timofte authored 4 days ago
2198
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
2199

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

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

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

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

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

            
2515
    seed_mdns_observations_from_yaml($dbh);
2516
}
2517

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

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

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

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

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

            
2575
    return $registry;
Bogdan Timofte authored 4 days ago
2576
}
2577

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
2994
sub fqdn_for_legacy_id {
2995
    my ($dbh, $legacy_id) = @_;
2996
    return '' unless length($legacy_id || '');
2997
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2998
    return $fqdn || '';
2999
}
3000

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

            
3016
sub legacy_id_from_fqdn {
3017
    my ($fqdn) = @_;
3018
    $fqdn = normalize_dns_name($fqdn);
3019
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
3020
    $fqdn =~ s/\..*\z//;
3021
    return clean_id($fqdn);
3022
}
3023

            
3024
sub normalize_dns_name {
3025
    my ($name) = @_;
3026
    $name = lc clean_scalar($name || '');
3027
    $name =~ s/\.\z//;
3028
    return $name;
3029
}
3030

            
3031
sub name_is_vhost {
3032
    my ($name) = @_;
3033
    $name = normalize_dns_name($name);
3034
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
3035
}
3036

            
Bogdan Timofte authored 4 days ago
3037
sub vhost_name_is_valid {
3038
    my ($name) = @_;
3039
    $name = normalize_dns_name($name);
3040
    return 0 unless length $name;
3041
    return 0 unless $name eq 'madagascar.xdev.ro' || $name =~ /\.madagascar\.xdev\.ro\z/;
3042
    return 0 unless length($name) <= 253;
3043
    for my $label (split /\./, $name) {
3044
        return 0 unless length($label) >= 1 && length($label) <= 63;
3045
        return 0 unless $label =~ /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/;
3046
    }
3047
    return 1;
3048
}
3049

            
Bogdan Timofte authored 4 days ago
3050
sub vhost_service_name {
3051
    my ($name) = @_;
3052
    $name = normalize_dns_name($name);
3053
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
3054
    return '';
3055
}
3056

            
3057
sub short_alias_for_fqdn {
3058
    my ($name) = @_;
3059
    $name = normalize_dns_name($name);
3060
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
3061
    return '';
3062
}
3063

            
Bogdan Timofte authored 4 days ago
3064
sub normalize_registry_policy {
3065
    my ($registry) = @_;
3066
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
3067
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
3068
    $registry->{policy}{runtime_database} = $opt{db};
3069
}
3070

            
3071
sub default_hosts_yaml {
3072
    return <<'YAML';
3073
version: 1
3074
updated_at: ""
3075
policy:
Bogdan Timofte authored 4 days ago
3076
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
3077
hosts:
3078
YAML
3079
}
3080

            
3081
sub default_work_orders_yaml {
3082
    return <<'YAML';
3083
version: 1
3084
work_orders:
3085
YAML
3086
}
3087

            
3088
sub ensure_parent_dir {
3089
    my ($path) = @_;
3090
    my $dir = dirname($path);
3091
    make_path($dir) unless -d $dir;
3092
}
3093

            
Xdev Host Manager authored a week ago
3094
sub url_decode {
3095
    my ($value) = @_;
3096
    $value = '' unless defined $value;
3097
    $value =~ tr/+/ /;
3098
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
3099
    return $value;
3100
}
3101

            
3102
sub random_hex {
3103
    my ($bytes) = @_;
3104
    if (open my $fh, '<:raw', '/dev/urandom') {
3105
        read($fh, my $raw, $bytes);
3106
        close $fh;
3107
        return unpack('H*', $raw);
3108
    }
3109
    return sha256_hex(rand() . time() . $$);
3110
}
3111

            
3112
sub iso_now {
3113
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
3114
}
3115

            
Bogdan Timofte authored 6 days ago
3116
sub build_info {
3117
    my %info = (
3118
        revision => '',
3119
        branch => '',
3120
        built_at => '',
3121
        deployed_at => '',
3122
        dirty => '',
3123
    );
3124

            
3125
    if ($ENV{HOST_MANAGER_BUILD}) {
3126
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
3127
        return \%info;
3128
    }
3129

            
3130
    my $build_file = "$project_dir/BUILD";
3131
    if (-f $build_file) {
3132
        for my $line (split /\n/, read_file($build_file)) {
3133
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
3134
            $info{$1} = clean_scalar($2);
3135
        }
3136
        return \%info if $info{revision} || $info{built_at};
3137
    }
3138

            
3139
    my $revision = git_value('rev-parse --short=12 HEAD');
3140
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
3141
    $info{revision} = $revision if $revision;
3142
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
3143
    return \%info;
3144
}
3145

            
3146
sub git_value {
3147
    my ($args) = @_;
3148
    return '' unless -d "$project_dir/.git";
3149
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
3150
    my $value = <$fh> || '';
3151
    close $fh;
3152
    chomp $value;
3153
    return clean_scalar($value);
3154
}
3155

            
3156
sub build_label {
3157
    my $info = build_info();
3158
    my $revision = $info->{revision} || 'unknown';
3159
    my $branch = $info->{branch} || '';
3160
    $branch = '' if $branch eq 'HEAD';
3161
    my $label = $branch ? "$branch $revision" : $revision;
3162
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
3163
    return $label;
3164
}
3165

            
3166
sub build_title {
3167
    my $info = build_info();
3168
    my $label = build_label();
3169
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
3170
    return $stamp ? "$label deployed $stamp" : $label;
3171
}
3172

            
Bogdan Timofte authored 4 days ago
3173
sub build_revision {
3174
    my $info = build_info();
3175
    return $info->{revision} || 'unknown';
3176
}
3177

            
3178
sub build_details {
3179
    my $info = build_info();
3180
    my %details = (
3181
        app => 'Madagascar Local Authority',
3182
        revision => $info->{revision} || 'unknown',
3183
        branch => $info->{branch} || '',
3184
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
3185
        built_at => $info->{built_at} || '',
3186
        deployed_at => $info->{deployed_at} || '',
3187
        label => build_label(),
3188
        title => build_title(),
3189
    );
3190
    return json_encode(\%details);
3191
}
3192

            
Bogdan Timofte authored 6 days ago
3193
sub html_escape {
3194
    my ($value) = @_;
3195
    $value = '' unless defined $value;
3196
    $value =~ s/&/&amp;/g;
3197
    $value =~ s/</&lt;/g;
3198
    $value =~ s/>/&gt;/g;
3199
    $value =~ s/"/&quot;/g;
3200
    $value =~ s/'/&#039;/g;
3201
    return $value;
3202
}
3203

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

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

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

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

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

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

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

            
Bogdan Timofte authored 5 days ago
3645
      <section class="page" id="page-dns" data-page="dns" hidden>
3646
        <section class="toolbar">
3647
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3648
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3649
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3650
          <button id="write-tsv">Write local-hosts.tsv</button>
3651
        </section>
Xdev Host Manager authored a week ago
3652
      </section>
3653

            
Bogdan Timofte authored 5 days ago
3654
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3655
        <section class="panel">
3656
          <div class="panel-head">
3657
            <h2>Work Orders</h2>
3658
            <div class="stats" id="wo-stats"></div>
3659
          </div>
3660
          <div class="problems" id="work-orders"></div>
3661
        </section>
Xdev Host Manager authored a week ago
3662
      </section>
3663

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

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

            
3745
  </div>
3746

            
Bogdan Timofte authored 4 days ago
3747
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3748
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3749
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3750
  </div>
Bogdan Timofte authored 6 days ago
3751

            
Xdev Host Manager authored a week ago
3752
  <script>
Bogdan Timofte authored 4 days ago
3753
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3754
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3755
    let hostFormBusy = false;
3756
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3757
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3758

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

            
Bogdan Timofte authored 4 days ago
3809
    function isAuthLost(error) {
3810
      return !!(error && error.authLost);
3811
    }
3812

            
3813
    function authLostError(message) {
3814
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3815
      error.authLost = true;
3816
      return error;
3817
    }
3818

            
3819
    function handleAuthLost(message) {
3820
      state.authenticated = false;
3821
      msg('');
3822
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3823
    }
3824

            
Bogdan Timofte authored 4 days ago
3825
    async function ensureAuthenticated(message) {
3826
      if (!state.authenticated) {
3827
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3828
        return false;
3829
      }
3830
      const session = await api('/api/session');
3831
      state.authenticated = session.authenticated;
3832
      if (!state.authenticated) {
3833
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3834
        return false;
3835
      }
3836
      return true;
3837
    }
3838

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

            
Bogdan Timofte authored 5 days ago
3861
    function currentPage() {
3862
      return PAGE_PATHS[window.location.pathname] || 'overview';
3863
    }
3864

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

            
Xdev Host Manager authored a week ago
3885
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3886
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3887
      document.body.classList.remove('is-app');
3888
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3889
      $('app').style.display = 'none';
3890
      $('login-screen').style.display = 'flex';
3891
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3892
      clearOtp();
Xdev Host Manager authored a week ago
3893
    }
3894

            
3895
    function showApp() {
Bogdan Timofte authored 6 days ago
3896
      document.body.classList.remove('is-login');
3897
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3898
      $('login-screen').style.display = 'none';
3899
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3900
      showPage(currentPage());
Xdev Host Manager authored a week ago
3901
    }
3902

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

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

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

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

            
3932
      renderHosts();
Bogdan Timofte authored 4 days ago
3933
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3934
      renderVhosts();
Xdev Host Manager authored a week ago
3935
    }
3936

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

            
Bogdan Timofte authored 5 days ago
4008
    function daysUntil(dateText) {
4009
      const time = Date.parse(dateText || '');
4010
      if (!Number.isFinite(time)) return null;
4011
      return Math.ceil((time - Date.now()) / 86400000);
4012
    }
4013

            
4014
    function certStatusClass(days) {
4015
      if (days === null) return '';
4016
      if (days < 0) return 'bad';
4017
      if (days <= 30) return 'warn';
4018
      return 'ok';
4019
    }
4020

            
4021
    function certStatusLabel(days) {
4022
      if (days === null) return 'validity unknown';
4023
      if (days < 0) return 'expired';
4024
      if (days === 0) return 'expires today';
4025
      return `${days}d remaining`;
4026
    }
4027

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

            
4037
        if (!state.workOrders.length) {
4038
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
4039
          return;
4040
        }
4041

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

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

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

            
4131
    function debugTableReference(database, tableName) {
4132
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
4133
    }
4134

            
4135
    async function selectDebugTable(tableName) {
4136
      state.debugTable = tableName || '';
4137
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4138
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
4139
        const card = button.closest('.debug-table-card');
4140
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
4141
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
4142
      });
4143
      if (state.debugTable) await renderDebugTable(state.debugTable);
4144
    }
4145

            
4146
    function clearDebugTable() {
4147
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
4148
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
4149
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4150
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4151
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4152
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
4153
    }
4154

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

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

            
Bogdan Timofte authored 4 days ago
4183
    function renderDebugRows(data) {
4184
      const rows = data.rows || [];
4185
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
4186
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
4187
    }
4188

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

            
4204
    function debugCell(value) {
4205
      if (value === null || value === undefined) return 'NULL';
4206
      if (Array.isArray(value)) return value.join(', ');
4207
      if (typeof value === 'object') return JSON.stringify(value);
4208
      return String(value);
4209
    }
4210

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

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

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

            
Bogdan Timofte authored 4 days ago
4306
    function renderHostAliasCell(host) {
4307
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
4308
        <span class="host-alias-label">${escapeHtml(name)}</span>
4309
        <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>
4310
      </span>`).join('');
4311
      return `<div class="host-alias-cell">
Bogdan Timofte authored 3 days ago
4312
        <div class="host-alias-list">${aliases}</div>
Bogdan Timofte authored 4 days ago
4313
      </div>`;
4314
    }
4315

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

            
4336
    function hostCertificateRow(host) {
4337
      return {
4338
        host_fqdn: host.fqdn || '',
4339
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
4340
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
4341
        certificate_id: host.certificate_id || '',
4342
        certificate: host.certificate || null,
4343
      };
Bogdan Timofte authored 4 days ago
4344
    }
4345

            
4346
    function vhostRows() {
Bogdan Timofte authored 4 days ago
4347
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
4348
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
4349
        vhost,
4350
        host_id: host.id || '',
4351
        host_fqdn: host.fqdn || '',
4352
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
4353
        monitoring: host.monitoring || '',
4354
        status: host.status || '',
Bogdan Timofte authored 4 days ago
4355
        certificate_id: '',
4356
        certificate: null,
Bogdan Timofte authored 4 days ago
4357
      })));
4358
    }
4359

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

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

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

            
4445
    function renderVhostEditor() {
4446
      const select = $('vhost-new-host');
4447
      const current = select.value || '';
4448
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
4449
    }
4450

            
4451
    function renderVhostHostOptions(selectedHostFqdn) {
4452
      return state.hosts
4453
        .slice()
4454
        .filter(host => (host.status || '') !== 'retired')
4455
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
4456
        .map(host => {
4457
          const fqdn = host.fqdn || '';
4458
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
4459
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
4460
        }).join('');
Bogdan Timofte authored 4 days ago
4461
    }
4462

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

            
Bogdan Timofte authored 4 days ago
4490
    function certificateIdOf(cert) {
Bogdan Timofte authored 4 days ago
4491
      return cert ? (cert.id || cert.name || '') : '';
4492
    }
4493

            
4494
    function certDnsNames(cert) {
4495
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
4496
        .map(name => String(name || '').toLowerCase())
4497
        .filter(Boolean);
4498
    }
4499

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

            
4524
    function certMatchesRow(cert, row) {
4525
      return certRelevance(cert, row) < 9;
4526
    }
4527

            
4528
    function compactCertificateLabel(cert, row) {
4529
      const relevance = certRelevance(cert, row);
4530
      const days = daysUntil(cert.not_after);
4531
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 4 days ago
4532
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 4 days ago
4533
      if (row && row.vhost) {
Bogdan Timofte authored 4 days ago
4534
        if (relevance === 0) return `${name}${suffix}`;
4535
        if (relevance === 1) return `host ${name}${suffix}`;
4536
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4537
      } else {
Bogdan Timofte authored 4 days ago
4538
        if (relevance === 0) return `${name}${suffix}`;
4539
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 4 days ago
4540
      }
Bogdan Timofte authored 4 days ago
4541
      return `${shortCertificateName(cert)}${suffix}`;
4542
    }
4543

            
Bogdan Timofte authored 4 days ago
4544
    function certificateDisplayName(cert) {
4545
      const commonName = String(cert.common_name || '').trim();
4546
      if (commonName) return commonName;
4547
      const dnsNames = certDnsNames(cert);
4548
      if (dnsNames.length) return dnsNames[0];
4549
      return shortCertificateName(cert);
4550
    }
4551

            
Bogdan Timofte authored 4 days ago
4552
    function shortCertificateName(cert) {
4553
      const name = String(cert.common_name || cert.name || cert.id || '');
4554
      const suffix = '.madagascar.xdev.ro';
4555
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
4556
    }
4557

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

            
Bogdan Timofte authored 4 days ago
4564
    function hostByFqdn(fqdn) {
4565
      fqdn = String(fqdn || '').toLowerCase();
4566
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
4567
    }
4568

            
4569
    function hostUpsertPayload(host, overrides = {}) {
4570
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
4571
      const payload = {
4572
        id: host.id || '',
4573
        fqdn: host.fqdn || '',
4574
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
4575
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
4576
        aliases,
4577
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
Bogdan Timofte authored 3 days ago
4578
        sources: [],
Bogdan Timofte authored 4 days ago
4579
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
4580
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
4581
      };
4582
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
4583
      return payload;
4584
    }
4585

            
Bogdan Timofte authored 3 days ago
4586
    function aliasEditorValues() {
4587
      return (hostField('aliases').value || '')
4588
        .split(/[\s,]+/)
4589
        .map(value => String(value || '').trim().toLowerCase())
4590
        .filter(Boolean);
4591
    }
4592

            
4593
    function appendAliasInEditor() {
4594
      const fqdn = String(hostField('fqdn').value || '').trim().toLowerCase();
4595
      const derived = shortAliasForFqdn(fqdn);
4596
      const alias = String(prompt(fqdn ? `Alias nou pentru ${fqdn}` : 'Alias nou', '') || '').trim().toLowerCase();
4597
      if (!alias) return;
4598
      if (fqdn && alias === fqdn) {
4599
        msg('fqdn-ul hostului este deja numele principal');
4600
        return;
4601
      }
4602
      if (derived && alias === derived) {
4603
        msg('aliasul derivat din fqdn se genereaza automat');
4604
        return;
4605
      }
4606
      const aliases = aliasEditorValues();
4607
      if (aliases.includes(alias)) {
4608
        msg(`aliasul ${alias} este deja in editor`);
4609
        return;
4610
      }
4611
      hostField('aliases').value = aliases.concat(alias).join('\n');
4612
      hostField('aliases').dispatchEvent(new Event('input', { bubbles: true }));
4613
      hostField('aliases').focus();
4614
    }
4615

            
Bogdan Timofte authored 4 days ago
4616
    async function addHostAlias(hostFqdn) {
4617
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4618
      const host = hostByFqdn(hostFqdn);
4619
      if (!host) return;
4620
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
4621
      if (!alias) return;
4622
      if (alias === String(host.fqdn || '').toLowerCase()) {
4623
        msg('fqdn-ul hostului este deja prezent');
4624
        return;
4625
      }
4626
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
4627
      await api('/api/hosts/upsert', {
4628
        method: 'POST',
4629
        headers: { 'Content-Type': 'application/json' },
4630
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4631
      });
4632
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
4633
      await refresh();
4634
    }
4635

            
4636
    async function removeHostAlias(hostFqdn, alias) {
4637
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4638
      const host = hostByFqdn(hostFqdn);
4639
      alias = String(alias || '').trim().toLowerCase();
4640
      if (!host || !alias) return;
4641
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
4642
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
4643
      await api('/api/hosts/upsert', {
4644
        method: 'POST',
4645
        headers: { 'Content-Type': 'application/json' },
4646
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
4647
      });
4648
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
4649
      await refresh();
4650
    }
4651

            
Bogdan Timofte authored 3 days ago
4652
    async function deleteHostInline(id) {
4653
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4654
      const host = state.hosts.find(entry => String(entry.id || '') === String(id || ''));
4655
      if (!host) return;
4656
      if (!confirm(`Delete ${host.fqdn || host.id || id}?`)) return;
4657
      await api('/api/hosts/delete', {
4658
        method: 'POST',
4659
        headers: { 'Content-Type': 'application/json' },
4660
        body: JSON.stringify({ id: host.id || id }),
4661
      });
4662
      if (hostEditorTarget === String(host.id || '')) closeHostForm(true);
4663
      msg(`host ${host.fqdn || host.id || id} deleted`);
4664
      await refresh();
4665
    }
4666

            
Bogdan Timofte authored 4 days ago
4667
    async function setHostCertificateFromSelect(select) {
4668
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4669
        select.value = select.dataset.currentCertificate || '';
4670
        return;
4671
      }
4672
      const hostFqdn = select.dataset.hostCertSelect || '';
4673
      const certificateId = select.value || '';
4674
      const current = select.dataset.currentCertificate || '';
4675
      if (!hostFqdn || certificateId === current) return;
4676
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
4677
        select.value = current;
4678
        return;
4679
      }
4680
      select.disabled = true;
4681
      try {
4682
        await api('/api/hosts/certificate', {
4683
          method: 'POST',
4684
          headers: { 'Content-Type': 'application/json' },
4685
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
4686
        });
4687
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
4688
        await refresh();
4689
      } finally {
4690
        select.disabled = false;
4691
      }
4692
    }
4693

            
4694
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
4695
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4696
      if (!hostFqdn) return;
4697
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
4698
      if (button) button.disabled = true;
4699
      try {
4700
        const result = await api('/api/hosts/issue-certificate', {
4701
          method: 'POST',
4702
          headers: { 'Content-Type': 'application/json' },
4703
          body: JSON.stringify({ host_fqdn: hostFqdn }),
4704
        });
4705
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
4706
        await refresh();
4707
      } finally {
4708
        if (button) button.disabled = false;
4709
      }
4710
    }
4711

            
Bogdan Timofte authored 4 days ago
4712
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
4713
      const vhost = select.dataset.vhostSelect || '';
4714
      const fromHost = select.dataset.currentHost || '';
4715
      const toHost = select.value || '';
4716
      if (!vhost || !toHost || toHost === fromHost) return;
4717
      select.disabled = true;
4718
      try {
4719
        await api('/api/vhosts/reassign', {
4720
          method: 'POST',
4721
          headers: { 'Content-Type': 'application/json' },
4722
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4723
        });
4724
        msg(`vhost ${vhost} moved`);
4725
        await refresh();
4726
      } finally {
4727
        select.disabled = false;
4728
      }
4729
    }
4730

            
Bogdan Timofte authored 4 days ago
4731
    async function addVhostInline() {
4732
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4733
      const nameInput = $('vhost-new-name');
4734
      const hostSelect = $('vhost-new-host');
4735
      const vhost = (nameInput.value || '').trim().toLowerCase();
4736
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 4 days ago
4737
      if (!vhost || !hostFqdn) {
4738
        msg('completeaza vhost si host');
4739
        return;
4740
      }
4741
      if (!isValidVhostName(vhost)) {
4742
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
4743
        nameInput.focus();
4744
        return;
4745
      }
4746
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
4747
        msg('vhost invalid: numele este deja host real');
4748
        nameInput.focus();
4749
        return;
4750
      }
Bogdan Timofte authored 4 days ago
4751
      $('vhost-add').disabled = true;
4752
      nameInput.disabled = true;
4753
      hostSelect.disabled = true;
4754
      try {
4755
        await api('/api/vhosts/upsert', {
4756
          method: 'POST',
4757
          headers: { 'Content-Type': 'application/json' },
4758
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4759
        });
4760
        nameInput.value = '';
4761
        msg(`vhost ${vhost} saved`);
4762
        await refresh();
Bogdan Timofte authored 4 days ago
4763
      } catch (e) {
4764
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
4765
      } finally {
4766
        $('vhost-add').disabled = false;
4767
        nameInput.disabled = false;
4768
        hostSelect.disabled = false;
4769
      }
4770
    }
4771

            
Bogdan Timofte authored 4 days ago
4772
    function isValidVhostName(name) {
4773
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
4774
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
4775
      if (name.length > 253) return false;
4776
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
4777
    }
4778

            
4779
    function vhostErrorMessage(error) {
4780
      const code = error && error.code ? error.code : '';
4781
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
4782
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
4783
      if (code === 'invalid_target_host') return 'host tinta invalid';
4784
      if (code === 'missing_target_host') return 'alege hostul tinta';
4785
      return error && error.message ? error.message : 'vhost add failed';
4786
    }
4787

            
Bogdan Timofte authored 4 days ago
4788
    async function setVhostCertificateFromSelect(select) {
4789
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4790
        select.value = select.dataset.currentCertificate || '';
4791
        return;
4792
      }
4793
      const vhost = select.dataset.vhostCertSelect || '';
4794
      const certificateId = select.value || '';
4795
      const current = select.dataset.currentCertificate || '';
4796
      if (!vhost || certificateId === current) return;
4797
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4798
        select.value = current;
4799
        return;
4800
      }
4801
      select.disabled = true;
4802
      try {
4803
        await api('/api/vhosts/certificate', {
4804
          method: 'POST',
4805
          headers: { 'Content-Type': 'application/json' },
4806
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4807
        });
4808
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4809
        await refresh();
4810
      } finally {
4811
        select.disabled = false;
4812
      }
4813
    }
4814

            
Bogdan Timofte authored 4 days ago
4815
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4816
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4817
      if (!vhost) return;
4818
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4819
      if (button) button.disabled = true;
4820
      try {
4821
        const result = await api('/api/vhosts/issue-certificate', {
4822
          method: 'POST',
4823
          headers: { 'Content-Type': 'application/json' },
4824
          body: JSON.stringify({ vhost_fqdn: vhost }),
4825
        });
4826
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4827
        await refresh();
4828
      } finally {
4829
        if (button) button.disabled = false;
4830
      }
4831
    }
4832

            
Bogdan Timofte authored 4 days ago
4833
    async function deleteVhostInline(vhost) {
4834
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4835
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4836
      await api('/api/vhosts/delete', {
4837
        method: 'POST',
4838
        headers: { 'Content-Type': 'application/json' },
4839
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4840
      });
4841
      msg(`vhost ${vhost} deleted`);
4842
      await refresh();
4843
    }
4844

            
Bogdan Timofte authored 4 days ago
4845
    async function editHost(id) {
4846
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4847
      const host = state.hosts.find(h => h.id === id);
4848
      if (!host) return;
Bogdan Timofte authored 4 days ago
4849
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4850
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4851
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4852
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4853
      hostField('roles').value = (host.roles || []).join(' ');
Bogdan Timofte authored 4 days ago
4854
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4855
    }
4856

            
Bogdan Timofte authored 4 days ago
4857
    async function newHost() {
4858
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4859
      if (!canSwitchHostEditor('__new__')) return;
4860
      resetHostForm(true);
4861
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4862
    }
4863

            
Bogdan Timofte authored 4 days ago
4864
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4865
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4866
      hostEditorTarget = target || '';
4867
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4868
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4869
      renderHosts();
4870
      hostFormSnapshot = hostFormState();
4871
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4872
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4873
    }
4874

            
Bogdan Timofte authored 4 days ago
4875
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4876
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4877
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4878
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4879
      hostField('status').value = 'active';
4880
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4881
      hostFormSnapshot = force ? '' : hostFormState();
4882
    }
4883

            
4884
    function closeHostForm(force = false) {
4885
      if (hostFormBusy && !force) return;
4886
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4887
      hostEditorTarget = '';
4888
      hostFormMode = 'new';
4889
      hostFormSnapshot = '';
4890
      clearHostFormMessage();
4891
      syncHostFormActions();
4892
      mountHostEditor();
4893
    }
4894

            
4895
    function canSwitchHostEditor(target) {
4896
      if (hostFormBusy) return false;
4897
      if (!hostEditorTarget) return true;
4898
      if (!hostFormDirty()) return true;
4899
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4900
      return confirm('Discard unsaved host changes?');
4901
    }
4902

            
4903
    function mountHostEditor() {
4904
      hostEditorRow.remove();
4905
      if (!hostEditorTarget) {
4906
        hostFormShell.hidden = true;
4907
        return;
4908
      }
Bogdan Timofte authored 3 days ago
4909
      hostEditorCell.colSpan = 8;
Bogdan Timofte authored 4 days ago
4910
      const tbody = $('hosts');
4911
      if (!tbody) return;
4912
      if (hostEditorTarget === '__new__') {
4913
        tbody.prepend(hostEditorRow);
4914
      } else {
4915
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4916
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4917
        if (targetRow) targetRow.after(hostEditorRow);
4918
        else tbody.prepend(hostEditorRow);
4919
      }
4920
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4921
    }
4922

            
4923
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4924
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4925
    }
4926

            
4927
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4928
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4929
    }
4930

            
4931
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4932
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4933
    }
4934

            
4935
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4936
      hostFormBusy = !!busy;
4937
      syncHostFormActions();
4938
    }
4939

            
4940
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4941
      saveHostButton.disabled = hostFormBusy;
4942
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4943
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
4944
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4945
    }
4946

            
4947
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4948
      hostFormMessage.textContent = text || '';
4949
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4950
    }
4951

            
4952
    function clearHostFormMessage() {
4953
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4954
    }
4955

            
4956
    function formObject(form) {
4957
      return Object.fromEntries(new FormData(form).entries());
4958
    }
4959

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

            
Bogdan Timofte authored 6 days ago
4965
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4966

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

            
4972
    if (loginAccount) {
4973
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4974
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4975
      loginAccount.addEventListener('input', () => {
4976
        const value = (loginAccount.value || '').trim();
4977
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4978
      });
4979
    }
4980

            
Xdev Host Manager authored a week ago
4981
    function setOtpDigit(idx, value) {
4982
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
4983
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4984
      otpDigits[idx].classList.toggle('filled', !!digit);
4985
    }
4986

            
Bogdan Timofte authored 4 days ago
4987
    // Move focus to the next empty box: forward from idx, then wrapping to the
4988
    // start. This lets out-of-order entry continue (e.g. after the last box,
4989
    // jump back to the first still-empty box). Stays put when all boxes are full.
4990
    function advanceFocus(idx) {
4991
      for (let i = idx + 1; i < otpDigits.length; i++) {
4992
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4993
      }
4994
      for (let i = 0; i <= idx; i++) {
4995
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4996
      }
4997
    }
4998

            
Bogdan Timofte authored 5 days ago
4999
    // Spread multiple digits across boxes starting at startIdx. Used for paste
5000
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
5001
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
5002
      const digits = (text || '').replace(/\D/g, '').split('');
5003
      if (!digits.length) return;
5004
      let last = startIdx;
5005
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
5006
        last = startIdx + i;
5007
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
5008
      }
Bogdan Timofte authored 5 days ago
5009
      syncOtpFields();
Bogdan Timofte authored 4 days ago
5010
      advanceFocus(last);
Xdev Host Manager authored a week ago
5011
      maybeSubmitOtp();
5012
    }
5013

            
Bogdan Timofte authored 5 days ago
5014
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
5015
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
5016
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
5017
    function maybeSubmitOtp() {
5018
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
5019
    }
5020
    function clearOtp() {
Bogdan Timofte authored 5 days ago
5021
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
5022
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
5023
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
5024
      // an unknown operator, so Safari's autofill anchor on the username stays.
5025
      if (loginAccount && !loginAccount.value) loginAccount.focus();
5026
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
5027
    }
5028

            
Bogdan Timofte authored 5 days ago
5029
    otpDigits.forEach((input, idx) => {
5030
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
5031
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5032
        // A single box may receive several digits at once (autofill / typing fast).
5033
        if (input.value.replace(/\D/g, '').length > 1) {
5034
          fillOtp(input.value, idx);
5035
          return;
5036
        }
5037
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
5038
        syncOtpFields();
Bogdan Timofte authored 4 days ago
5039
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
5040
        maybeSubmitOtp();
5041
      });
Bogdan Timofte authored 5 days ago
5042

            
5043
      input.addEventListener('paste', (e) => {
5044
        e.preventDefault();
Bogdan Timofte authored 4 days ago
5045
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5046
        const text = (e.clipboardData || window.clipboardData).getData('text');
5047
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
5048
      });
Bogdan Timofte authored 5 days ago
5049

            
5050
      input.addEventListener('keydown', (e) => {
5051
        if (e.key === 'Backspace') {
5052
          e.preventDefault();
Bogdan Timofte authored 4 days ago
5053
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
5054
          if (input.value) { setOtpDigit(idx, ''); }
5055
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
5056
          syncOtpFields();
5057
        } else if (e.key === 'ArrowLeft' && idx > 0) {
5058
          e.preventDefault();
5059
          otpDigits[idx - 1].focus();
5060
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
5061
          e.preventDefault();
5062
          otpDigits[idx + 1].focus();
5063
        }
5064
      });
5065
    });
5066

            
Bogdan Timofte authored 4 days ago
5067
    // Focus the first OTP box only for a returning operator (username known).
5068
    // For an unknown operator, leave focus on the username field so Safari can
5069
    // present its OTP autofill anchored there without being dismissed by a focus
5070
    // change (pbx-admin pattern).
5071
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
5072
    else if (loginAccount) loginAccount.focus();
5073
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
5074

            
Bogdan Timofte authored 5 days ago
5075
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
5076
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
5077
        event.preventDefault();
Bogdan Timofte authored 4 days ago
5078
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
5079
        showPage(link.dataset.pageLink, true);
5080
      });
5081
    });
5082

            
Bogdan Timofte authored 4 days ago
5083
    window.addEventListener('popstate', () => {
5084
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
5085
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
5086
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
5087
    });
Bogdan Timofte authored 5 days ago
5088

            
Bogdan Timofte authored 4 days ago
5089
    async function copyText(text) {
5090
      if (navigator.clipboard && window.isSecureContext) {
5091
        await navigator.clipboard.writeText(text);
5092
        return;
5093
      }
5094
      const input = document.createElement('textarea');
5095
      input.value = text;
5096
      input.setAttribute('readonly', '');
5097
      input.style.position = 'fixed';
5098
      input.style.left = '-10000px';
5099
      document.body.appendChild(input);
5100
      input.select();
5101
      document.execCommand('copy');
5102
      document.body.removeChild(input);
5103
    }
5104

            
5105
    $('copy-build').addEventListener('click', async () => {
5106
      try {
5107
        await copyText($('copy-build').dataset.buildDetails || '');
5108
        if (state.authenticated) msg('build details copied');
5109
      } catch (e) {
5110
        if (state.authenticated) msg('copy failed');
5111
      }
5112
    });
5113

            
Xdev Host Manager authored a week ago
5114
    $('login-form').addEventListener('submit', async (event) => {
5115
      event.preventDefault();
Bogdan Timofte authored 5 days ago
5116
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
5117
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
5118
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
5119
      try {
Xdev Host Manager authored a week ago
5120
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
5121
        await refresh();
Xdev Host Manager authored a week ago
5122
      } catch (e) {
5123
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
5124
      } finally {
Xdev Host Manager authored a week ago
5125
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
5126
      }
Xdev Host Manager authored a week ago
5127
    });
5128

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

            
Bogdan Timofte authored 4 days ago
5134
    $('refresh').addEventListener('click', () => refresh().catch(e => {
5135
      if (!isAuthLost(e)) msg(e.message);
5136
    }));
Xdev Host Manager authored a week ago
5137
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
5138
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
5139
    $('vhost-add').addEventListener('click', () => {
5140
      addVhostInline().catch(e => {
5141
        if (!isAuthLost(e)) msg(e.message);
5142
      });
5143
    });
5144
    $('vhost-new-name').addEventListener('keydown', (event) => {
5145
      if (event.key !== 'Enter') return;
5146
      event.preventDefault();
5147
      addVhostInline().catch(e => {
5148
        if (!isAuthLost(e)) msg(e.message);
5149
      });
5150
    });
Bogdan Timofte authored 4 days ago
5151
    $('new-host').addEventListener('click', () => {
5152
      newHost().catch(e => {
5153
        if (!isAuthLost(e)) msg(e.message);
5154
      });
5155
    });
Bogdan Timofte authored 4 days ago
5156
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
5157
      if (!isAuthLost(e)) msg(e.message);
5158
    }));
Bogdan Timofte authored 3 days ago
5159
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 4 days ago
5160
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
5161

            
Bogdan Timofte authored 4 days ago
5162
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
5163
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5164
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
5165
      setHostFormBusy(true);
5166
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
5167
      try {
5168
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
5169
        msg('host saved');
5170
        await refresh();
Bogdan Timofte authored 3 days ago
5171
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5172
      } catch (e) {
Bogdan Timofte authored 4 days ago
5173
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5174
        setHostFormMessage(e.message, true);
5175
        msg(e.message);
5176
      } finally {
5177
        setHostFormBusy(false);
5178
      }
5179
    });
5180

            
Bogdan Timofte authored 4 days ago
5181
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
5182
      setHostFormMessage('Complete the required host fields before saving.', true);
5183
    }, true);
5184

            
Bogdan Timofte authored 4 days ago
5185
    hostForm.addEventListener('input', () => {
5186
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
5187
    });
5188

            
Bogdan Timofte authored 4 days ago
5189
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
5190
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
5191
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
5192
      setHostFormBusy(true);
5193
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
5194
      try {
5195
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
5196
        msg('host deleted');
5197
        await refresh();
Bogdan Timofte authored 4 days ago
5198
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5199
      } catch (e) {
Bogdan Timofte authored 4 days ago
5200
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5201
        setHostFormMessage(e.message, true);
5202
        msg(e.message);
5203
      } finally {
5204
        setHostFormBusy(false);
5205
      }
Xdev Host Manager authored a week ago
5206
    });
5207

            
Bogdan Timofte authored 4 days ago
5208
    resetHostForm(true);
5209
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
5210

            
Xdev Host Manager authored a week ago
5211
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
5212
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
5213
      try {
5214
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
5215
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
5216
      } catch (e) {
5217
        if (!isAuthLost(e)) msg(e.message);
5218
      }
Xdev Host Manager authored a week ago
5219
    });
5220

            
Bogdan Timofte authored 4 days ago
5221
    refresh().catch(e => {
5222
      if (!isAuthLost(e)) showLogin(e.message);
5223
    });
Xdev Host Manager authored a week ago
5224
  </script>
5225
</body>
5226
</html>
5227
HTML
Bogdan Timofte authored 6 days ago
5228
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
5229
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
5230
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
5231
    return $html;
Xdev Host Manager authored a week ago
5232
}