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

            
6
use strict;
7
use warnings;
8

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

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

            
21
my %opt = (
22
    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
23
    port => $ENV{HOST_MANAGER_PORT} || 8088,
Bogdan Timofte authored 4 days ago
24
    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
Xdev Host Manager authored a week ago
25
    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
Bogdan Timofte authored 2 days ago
26
    dns_publish_trigger => $ENV{HOST_MANAGER_DNS_PUBLISH_TRIGGER} || "$project_dir/var/dns-publish.trigger",
Xdev Host Manager authored a week ago
27
    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
Xdev Host Manager authored a week ago
28
);
Bogdan Timofte authored a day ago
29
my $print_resolver_records = 0;
30
my $print_hosts_yaml = 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;
Xdev Host Manager authored a week ago
42
    } elsif ($arg eq '--work-orders') {
43
        $opt{work_orders} = shift @ARGV;
Bogdan Timofte authored a day ago
44
    } elsif ($arg eq '--print-hosts-yaml') {
45
        $print_hosts_yaml = 1;
46
    } elsif ($arg eq '--print-resolver-records' || $arg eq '--print-local-hosts-tsv') {
47
        $print_resolver_records = 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 a day ago
56
if ($print_hosts_yaml) {
57
    print render_hosts_yaml(load_registry());
58
    exit 0;
59
}
60

            
61
if ($print_resolver_records) {
62
    print render_resolver_records(load_registry());
Bogdan Timofte authored 3 days ago
63
    exit 0;
64
}
65

            
Xdev Host Manager authored a week ago
66
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
67
my %sessions;
68

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

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

            
82
while (my $client = $server->accept) {
83
    eval {
84
        $client->autoflush(1);
85
        handle_client($client);
86
    };
87
    if ($@) {
88
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
89
    }
90
    close $client;
91
}
92

            
93
sub usage {
94
    print <<"EOF";
95
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
96

            
97
Environment:
98
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
99
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
Bogdan Timofte authored 3 days ago
100
  HOST_MANAGER_DHCP_PUSH_TOKEN  Token for DHCP lease push collector.
Bogdan Timofte authored 4 days ago
101
  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
Xdev Host Manager authored a week ago
102
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
Bogdan Timofte authored 2 days ago
103
  HOST_MANAGER_DNS_PUBLISH_TRIGGER
104
                                  Defaults to var/dns-publish.trigger.
Xdev Host Manager authored a week ago
105
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Bogdan Timofte authored a day ago
106
  --print-hosts-yaml            Print the finished hosts.yaml export and exit.
107
  --print-resolver-records      Print ephemeral resolver records and exit.
Xdev Host Manager authored a week ago
108

            
Bogdan Timofte authored a day ago
109
SQLite is the runtime source of truth. hosts.yaml is the finished registry
110
export. Resolver sync is an action sourced from SQLite, not a tracked TSV
111
artifact. The nginx vhost keeps registry, CA, work order and download endpoints
112
behind OTP.
Xdev Host Manager authored a week ago
113
EOF
114
}
115

            
116
sub handle_client {
117
    my ($client) = @_;
118
    my $request_line = <$client>;
119
    return unless defined $request_line;
120
    $request_line =~ s/\r?\n$//;
121
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
122
    return send_text($client, 400, 'bad request') unless $method && $target;
123

            
124
    my %headers;
125
    while (my $line = <$client>) {
126
        $line =~ s/\r?\n$//;
127
        last if $line eq '';
128
        my ($k, $v) = split /:\s*/, $line, 2;
129
        $headers{lc $k} = $v if defined $k && defined $v;
130
    }
131

            
132
    my $body = '';
133
    if (($headers{'content-length'} || 0) > 0) {
134
        read($client, $body, int($headers{'content-length'}));
135
    }
136

            
137
    my ($path, $query) = split /\?/, $target, 2;
138
    my %query = parse_params($query || '');
139

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

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

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

            
225
    if ($method eq 'POST' && $path =~ m{^/api/}) {
226
        if ($path eq '/api/hosts/upsert') {
227
            my $payload = request_payload(\%headers, $body);
228
            return upsert_host($client, $payload);
229
        }
230
        if ($path eq '/api/hosts/delete') {
231
            my $payload = request_payload(\%headers, $body);
Bogdan Timofte authored a day ago
232
            return delete_host($client, $payload->{fqdn} || $payload->{id} || '');
233
        }
234
        if ($path eq '/api/tags/upsert') {
235
            my $payload = request_payload(\%headers, $body);
236
            return upsert_tag($client, $payload);
237
        }
238
        if ($path eq '/api/tags/rename') {
239
            my $payload = request_payload(\%headers, $body);
240
            return rename_tag($client, $payload);
241
        }
242
        if ($path eq '/api/tags/delete') {
243
            my $payload = request_payload(\%headers, $body);
244
            return delete_tag($client, $payload);
245
        }
246
        if ($path eq '/api/dns/publish') {
247
            my $publish = publish_dns_change(load_registry(), 'manual-publish');
248
            return send_json($client, 200, { ok => json_bool(1), dns_publish => $publish });
Xdev Host Manager authored a week ago
249
        }
Bogdan Timofte authored 3 days ago
250
        if ($path eq '/api/hosts/certificate') {
251
            my $payload = request_payload(\%headers, $body);
252
            return set_host_certificate($client, $payload);
253
        }
254
        if ($path eq '/api/hosts/issue-certificate') {
255
            my $payload = request_payload(\%headers, $body);
256
            return issue_host_certificate($client, $payload);
257
        }
Bogdan Timofte authored 4 days ago
258
        if ($path eq '/api/vhosts/reassign') {
259
            my $payload = request_payload(\%headers, $body);
260
            return reassign_vhost($client, $payload);
261
        }
Bogdan Timofte authored 4 days ago
262
        if ($path eq '/api/vhosts/upsert') {
263
            my $payload = request_payload(\%headers, $body);
264
            return upsert_vhost($client, $payload);
265
        }
266
        if ($path eq '/api/vhosts/delete') {
267
            my $payload = request_payload(\%headers, $body);
268
            return delete_vhost($client, $payload);
269
        }
Bogdan Timofte authored 3 days ago
270
        if ($path eq '/api/vhosts/certificate') {
271
            my $payload = request_payload(\%headers, $body);
272
            return set_vhost_certificate($client, $payload);
273
        }
274
        if ($path eq '/api/vhosts/issue-certificate') {
275
            my $payload = request_payload(\%headers, $body);
276
            return issue_vhost_certificate($client, $payload);
277
        }
Xdev Host Manager authored a week ago
278
        if ($path eq '/api/work-orders/confirm') {
279
            my $payload = request_payload(\%headers, $body);
280
            return confirm_work_order($client, $payload);
281
        }
Xdev Host Manager authored a week ago
282
        if ($path eq '/api/work-orders/checklist') {
283
            my $payload = request_payload(\%headers, $body);
284
            return update_work_order_checklist($client, $payload);
285
        }
Xdev Host Manager authored a week ago
286
    }
287

            
288
    return send_json($client, 404, { error => 'not_found' });
289
}
290

            
Bogdan Timofte authored 5 days ago
291
sub app_page_path {
292
    my ($path) = @_;
Bogdan Timofte authored a day ago
293
    return $path =~ m{\A/(?:|overview|hosts|vhosts|tags|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
294
}
295

            
Xdev Host Manager authored a week ago
296
sub load_registry {
Bogdan Timofte authored 4 days ago
297
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
298
    normalize_registry_policy($registry);
299
    return $registry;
Xdev Host Manager authored a week ago
300
}
301

            
302
sub save_registry {
303
    my ($registry) = @_;
304
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
305
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
306
    save_registry_to_db($registry);
Bogdan Timofte authored 2 days ago
307
    return publish_dns_change($registry, 'registry-save');
Xdev Host Manager authored a week ago
308
}
309

            
Xdev Host Manager authored a week ago
310
sub load_work_orders {
Bogdan Timofte authored 4 days ago
311
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
312
}
313

            
314
sub save_work_orders {
315
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
316
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
317
}
318

            
319
sub work_orders_payload {
320
    my ($orders) = @_;
321
    my $pending = 0;
322
    for my $wo (@{ $orders->{work_orders} || [] }) {
323
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
324
    }
325
    return {
326
        version => $orders->{version},
327
        work_orders => $orders->{work_orders} || [],
328
        counts => {
329
            work_orders => scalar @{ $orders->{work_orders} || [] },
330
            pending => $pending,
331
        },
332
    };
333
}
334

            
Bogdan Timofte authored 3 days ago
335
sub collect_dhcp_leases {
336
    my ($client, $headers, $body) = @_;
337
    my $expected = $ENV{HOST_MANAGER_DHCP_PUSH_TOKEN} || '';
338
    return send_json($client, 503, { error => 'dhcp_push_not_configured' }) unless length $expected;
339

            
340
    my $provided = dhcp_push_token_from_headers($headers);
341
    return send_json($client, 401, { error => 'invalid_dhcp_push_token' }) unless token_matches($expected, $provided);
342

            
343
    my $payload = request_payload($headers, $body);
344
    my @leases = dhcp_payload_leases($payload);
345
    return send_json($client, 400, { error => 'missing_dhcp_leases' }) unless @leases;
346

            
347
    my $dbh = dbh();
348
    my $now = iso_now();
349
    my $worker_id = clean_id($payload->{worker_id} || $payload->{source_id} || 'dhcp-router');
350
    $worker_id ||= 'dhcp-router';
351
    my @stored;
352
    with_transaction($dbh, sub {
353
        upsert_dhcp_worker($dbh, $worker_id, $now);
354
        for my $lease (@leases) {
355
            my $stored = upsert_dhcp_lease($dbh, $worker_id, $lease, $now);
356
            push @stored, $stored if $stored;
357
        }
358
        $dbh->do(
359
            'UPDATE data_workers SET last_run_at = ?, updated_at = ? WHERE worker_id = ?',
360
            undef,
361
            $now,
362
            $now,
363
            $worker_id,
364
        );
365
    });
366

            
367
    return send_json($client, 200, {
368
        ok => json_bool(1),
369
        worker_id => $worker_id,
370
        stored => scalar(@stored),
371
        leases => \@stored,
372
    });
373
}
374

            
375
sub dhcp_push_token_from_headers {
376
    my ($headers) = @_;
377
    my $token = clean_scalar($headers->{'x-dhcp-push-token'} || '');
378
    return $token if length $token;
379
    my $authorization = clean_scalar($headers->{authorization} || '');
380
    return $1 if $authorization =~ /\ABearer\s+(.+)\z/i;
381
    return '';
382
}
383

            
384
sub token_matches {
385
    my ($expected, $provided) = @_;
386
    return 0 unless length($expected || '') && length($provided || '');
387
    return 0 unless length($expected) == length($provided);
388
    my $diff = 0;
389
    for my $i (0 .. length($expected) - 1) {
390
        $diff |= ord(substr($expected, $i, 1)) ^ ord(substr($provided, $i, 1));
391
    }
392
    return $diff == 0 ? 1 : 0;
393
}
394

            
395
sub dhcp_payload_leases {
396
    my ($payload) = @_;
397
    return () unless ref($payload) eq 'HASH';
398
    if (ref($payload->{leases}) eq 'ARRAY') {
399
        return grep { ref($_) eq 'HASH' } @{ $payload->{leases} };
400
    }
401
    return ($payload);
402
}
403

            
404
sub upsert_dhcp_worker {
405
    my ($dbh, $worker_id, $now) = @_;
406
    $dbh->do(
407
        'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
408
        . "VALUES (?, 'dhcp', 'Router DHCP leases', 'active', 'push:192.168.2.1', ?, 'DHCP lease push collector source.', ?, ?) "
409
        . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, status = excluded.status, '
410
        . 'source = excluded.source, last_run_at = excluded.last_run_at, notes = excluded.notes, updated_at = excluded.updated_at',
411
        undef,
412
        $worker_id,
413
        $now,
414
        $now,
415
        $now,
416
    );
417
}
418

            
419
sub upsert_dhcp_lease {
420
    my ($dbh, $worker_id, $lease, $now) = @_;
421
    my $ip = clean_ip($lease->{ip_address} || $lease->{ip} || $lease->{address} || '');
422
    my $mac = clean_mac($lease->{mac_address} || $lease->{mac} || $lease->{active_mac} || '');
423
    return unless length $ip || length $mac;
424

            
425
    my $name = normalize_dhcp_name($lease->{observed_name} || $lease->{host_name} || $lease->{hostname} || $lease->{name} || '');
426
    my $state = clean_scalar($lease->{lease_state} || $lease->{state} || $lease->{status} || '');
427
    if (!length $state && exists $lease->{bound}) {
428
        $state = ($lease->{bound} || '') eq '1' ? 'bound' : 'unbound';
429
    }
430
    $state ||= 'observed';
431

            
432
    my $lease_key = length $mac ? "$worker_id|mac|$mac" : "$worker_id|ip|$ip";
433
    my $host_fqdn = match_dhcp_host_fqdn($dbh, $name, $ip);
434
    my $raw = json_encode($lease);
435
    $dbh->do(
436
        'INSERT INTO dhcp_leases (lease_key, worker_id, host_fqdn, observed_name, ip_address, mac_address, lease_state, first_seen, last_seen, raw) '
437
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
438
        . 'ON CONFLICT(lease_key) DO UPDATE SET host_fqdn = excluded.host_fqdn, observed_name = excluded.observed_name, '
439
        . 'ip_address = excluded.ip_address, mac_address = excluded.mac_address, lease_state = excluded.lease_state, '
440
        . 'last_seen = excluded.last_seen, raw = excluded.raw',
441
        undef,
442
        $lease_key,
443
        $worker_id,
444
        length($host_fqdn) ? $host_fqdn : undef,
445
        $name,
446
        $ip,
447
        $mac,
448
        $state,
449
        $now,
450
        $now,
451
        $raw,
452
    );
453

            
454
    return {
455
        lease_key => $lease_key,
456
        host_fqdn => $host_fqdn,
457
        observed_name => $name,
458
        ip_address => $ip,
459
        mac_address => $mac,
460
        lease_state => $state,
461
    };
462
}
463

            
464
sub match_dhcp_host_fqdn {
465
    my ($dbh, $name, $ip) = @_;
466
    my @names;
467
    $name = normalize_dns_name($name || '');
468
    if (length $name) {
469
        push @names, $name;
470
        push @names, "$name.madagascar.xdev.ro" unless $name =~ /\./;
471
    }
472
    for my $candidate (unique_preserve(@names)) {
473
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ? AND status <> ?', undef, $candidate, 'retired');
474
        return $fqdn if $fqdn;
475
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $candidate, 'active');
476
        return $fqdn if $fqdn;
477
    }
478
    if (length($ip || '')) {
479
        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');
480
        return $fqdn if $fqdn;
481
    }
482
    return '';
483
}
484

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

            
491
    my $orders = load_work_orders();
492
    my $work_order;
493
    for my $wo (@{ $orders->{work_orders} || [] }) {
494
        if (($wo->{id} || '') eq $id) {
495
            $work_order = $wo;
496
            last;
497
        }
498
    }
499
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
500
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
501
    my $incomplete = incomplete_work_order_items($work_order);
502
    return send_json($client, 409, {
503
        error => 'work_order_incomplete',
504
        incomplete => $incomplete,
505
    }) if @$incomplete;
Xdev Host Manager authored a week ago
506

            
507
    my $registry = load_registry();
508
    my $results = apply_work_order($registry, $work_order);
509
    $work_order->{status} = 'confirmed';
510
    $work_order->{confirmed_at} = iso_now();
511
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
512

            
Bogdan Timofte authored 2 days ago
513
    my $publish = save_registry($registry);
Xdev Host Manager authored a week ago
514
    save_work_orders($orders);
515

            
516
    return send_json($client, 200, {
517
        ok => json_bool(1),
518
        work_order => $work_order,
519
        results => $results,
Bogdan Timofte authored 2 days ago
520
        dns_publish => $publish,
Xdev Host Manager authored a week ago
521
    });
522
}
523

            
Xdev Host Manager authored a week ago
524
sub update_work_order_checklist {
525
    my ($client, $payload) = @_;
526
    my $id = clean_scalar($payload->{id} || '');
527
    my $item_id = clean_scalar($payload->{item_id} || '');
528
    my $status = clean_scalar($payload->{status} || '');
529
    my $notes = clean_scalar($payload->{notes} || '');
530
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
531
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
532
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
533

            
534
    my $orders = load_work_orders();
535
    my $work_order;
536
    for my $wo (@{ $orders->{work_orders} || [] }) {
537
        if (($wo->{id} || '') eq $id) {
538
            $work_order = $wo;
539
            last;
540
        }
541
    }
542
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
543
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
544

            
545
    my $item;
546
    for my $candidate (@{ $work_order->{checklist} || [] }) {
547
        if (($candidate->{id} || '') eq $item_id) {
548
            $item = $candidate;
549
            last;
550
        }
551
    }
552
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
553

            
554
    $item->{status} = $status;
555
    $item->{updated_at} = iso_now();
556
    $item->{notes} = $notes if length $notes;
557
    save_work_orders($orders);
558
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
559
}
560

            
Bogdan Timofte authored a day ago
561
sub upsert_tag {
562
    my ($client, $payload) = @_;
563
    my $label = clean_tag_label($payload->{label} || $payload->{tag} || $payload->{tag_id} || '');
564
    return send_json($client, 400, { error => 'invalid_tag' }) unless length $label;
565
    my $dbh = dbh();
566
    my $now = iso_now();
567
    my $tag = eval {
568
        with_transaction($dbh, sub {
569
            upsert_tag_to_db(
570
                $dbh,
571
                $label,
572
                clean_tag_color($payload->{color} || ''),
573
                clean_tag_icon($payload->{icon} || ''),
574
                clean_scalar($payload->{notes} || ''),
575
                $now,
576
            );
577
        });
578
        tag_payload_by_label($dbh, $label);
579
    };
580
    if (!$tag) {
581
        return send_json($client, 409, { error => 'tag_upsert_failed', detail => clean_scalar($@ || '') });
582
    }
583
    return send_json($client, 200, { ok => json_bool(1), tag => $tag });
584
}
585

            
586
sub rename_tag {
587
    my ($client, $payload) = @_;
588
    my $old_id = clean_tag_id($payload->{tag_id} || $payload->{old_label} || $payload->{old_tag} || '');
589
    my $new_label = clean_tag_label($payload->{new_label} || $payload->{label} || '');
590
    return send_json($client, 400, { error => 'invalid_tag' }) unless length $old_id && length $new_label;
591
    my $new_id = clean_tag_id($new_label);
592
    my $dbh = dbh();
593
    return send_json($client, 404, { error => 'tag_not_found' })
594
        unless db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ?', $old_id);
595
    if ($new_id ne $old_id && db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ? OR label = ?', $new_id, $new_label)) {
596
        return send_json($client, 409, { error => 'tag_exists' });
597
    }
598
    my $now = iso_now();
599
    my $renamed = eval {
600
        with_transaction($dbh, sub {
601
            $dbh->do(
602
                'UPDATE tags SET tag_id = ?, label = ?, color = ?, icon = ?, notes = ?, updated_at = ? WHERE tag_id = ?',
603
                undef,
604
                $new_id,
605
                $new_label,
606
                clean_tag_color($payload->{color} || ''),
607
                clean_tag_icon($payload->{icon} || ''),
608
                clean_scalar($payload->{notes} || ''),
609
                $now,
610
                $old_id,
611
            );
612
            set_schema_meta($dbh, 'registry_updated_at', $now);
613
        });
614
        tag_payload_by_label($dbh, $new_label);
615
    };
616
    if (!$renamed) {
617
        return send_json($client, 409, { error => 'tag_rename_failed', detail => clean_scalar($@ || '') });
618
    }
619
    return send_json($client, 200, { ok => json_bool(1), tag => $renamed });
620
}
621

            
622
sub delete_tag {
623
    my ($client, $payload) = @_;
624
    my $tag_id = clean_tag_id($payload->{tag_id} || $payload->{label} || '');
625
    return send_json($client, 400, { error => 'invalid_tag' }) unless length $tag_id;
626
    my $dbh = dbh();
627
    return send_json($client, 404, { error => 'tag_not_found' })
628
        unless db_scalar($dbh, 'SELECT COUNT(*) FROM tags WHERE tag_id = ?', $tag_id);
629
    my $now = iso_now();
630
    with_transaction($dbh, sub {
631
        $dbh->do('DELETE FROM host_tags WHERE tag_id = ?', undef, $tag_id);
632
        $dbh->do('DELETE FROM tags WHERE tag_id = ?', undef, $tag_id);
633
        set_schema_meta($dbh, 'registry_updated_at', $now);
634
    });
635
    return send_json($client, 200, { ok => json_bool(1), tag_id => $tag_id });
636
}
637

            
Xdev Host Manager authored a week ago
638
sub incomplete_work_order_items {
639
    my ($work_order) = @_;
640
    my @incomplete;
641
    for my $item (@{ $work_order->{checklist} || [] }) {
642
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
643
    }
644
    return \@incomplete;
645
}
646

            
Xdev Host Manager authored a week ago
647
sub apply_work_order {
648
    my ($registry, $work_order) = @_;
649
    my @results;
650
    for my $action (@{ $work_order->{actions} || [] }) {
651
        my $type = $action->{type} || '';
652
        if ($type eq 'remove_name') {
653
            my $host_id = $action->{host_id} || '';
654
            my $name = $action->{name} || '';
655
            my $removed = 0;
656
            for my $host (@{ $registry->{hosts} || [] }) {
657
                next unless ($host->{id} || '') eq $host_id;
Bogdan Timofte authored 4 days ago
658
                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
659
                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
660
                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
661
                $host->{aliases} = \@kept_aliases;
662
                $host->{vhosts} = \@kept_vhosts;
Xdev Host Manager authored a week ago
663
                last;
664
            }
665
            push @results, {
666
                type => $type,
667
                host_id => $host_id,
668
                name => $name,
669
                removed => json_bool($removed),
670
            };
671
        } else {
672
            die "Unsupported work order action: $type\n";
673
        }
674
    }
675
    return \@results;
676
}
677

            
Xdev Host Manager authored a week ago
678
sub registry_payload {
679
    my ($registry) = @_;
680
    my $problems = analyze_hosts($registry->{hosts});
Bogdan Timofte authored 3 days ago
681
    my $dbh = dbh();
Bogdan Timofte authored 3 days ago
682
    my %host_tls = host_tls_payloads($dbh);
Bogdan Timofte authored a day ago
683
    my %tag_catalog = tag_catalog_by_label($dbh);
684
    my @hosts = map { host_payload($_, $host_tls{ canonical_host_fqdn($_) }, \%tag_catalog) } @{ $registry->{hosts} };
Bogdan Timofte authored 3 days ago
685
    my @vhosts = vhost_payloads($dbh);
686
    my @certificates = certificate_payloads($dbh);
Bogdan Timofte authored a day ago
687
    my $tags = tags_payload($dbh);
Bogdan Timofte authored 4 days ago
688
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
689
    return {
690
        version => $registry->{version},
691
        updated_at => $registry->{updated_at},
692
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
693
        hosts => \@hosts,
Bogdan Timofte authored 3 days ago
694
        vhosts => \@vhosts,
Bogdan Timofte authored a day ago
695
        tags => $tags->{tags},
696
        observations => observations_payload($dbh),
Bogdan Timofte authored 3 days ago
697
        certificates => \@certificates,
Xdev Host Manager authored a week ago
698
        problems => $problems,
699
        counts => {
700
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 3 days ago
701
            vhosts => scalar(@vhosts) || $vhost_count,
Xdev Host Manager authored a week ago
702
            problems => scalar @$problems,
703
        },
704
    };
705
}
706

            
Bogdan Timofte authored 3 days ago
707
sub host_tls_payloads {
708
    my ($dbh) = @_;
709
    my %rows;
710
    my $sth = $dbh->prepare(<<'SQL');
711
SELECT
712
    ht.host_fqdn,
713
    ht.certificate_id,
714
    c.common_name,
715
    c.not_after,
716
    c.fingerprint_sha256,
717
    c.status AS certificate_status
718
FROM host_tls ht
719
LEFT JOIN certificates c ON c.certificate_id = ht.certificate_id
720
ORDER BY ht.host_fqdn
721
SQL
722
    $sth->execute;
723
    while (my $row = $sth->fetchrow_hashref) {
724
        my $host_fqdn = clean_scalar($row->{host_fqdn} || '');
725
        next unless length $host_fqdn;
726
        my $cert_id = clean_scalar($row->{certificate_id} || '');
727
        my %payload = (
728
            certificate_id => $cert_id,
729
        );
730
        if (length $cert_id) {
731
            $payload{certificate} = {
732
                id => $cert_id,
733
                name => $cert_id,
734
                common_name => clean_scalar($row->{common_name} || ''),
735
                status => clean_scalar($row->{certificate_status} || ''),
736
                not_after => clean_scalar($row->{not_after} || ''),
737
                fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
738
                has_private_key => json_bool(ca_private_key_exists($cert_id)),
739
            };
740
        }
741
        $rows{$host_fqdn} = \%payload;
742
    }
743
    return %rows;
744
}
745

            
Bogdan Timofte authored 3 days ago
746
sub vhost_payloads {
747
    my ($dbh) = @_;
748
    my @rows;
749
    my $sth = $dbh->prepare(<<'SQL');
750
SELECT
751
    v.vhost_fqdn,
752
    v.host_fqdn,
753
    v.status AS vhost_status,
754
    v.certificate_id,
755
    h.legacy_id,
756
    h.monitoring,
757
    h.status AS host_status,
758
    c.common_name,
759
    c.not_after,
760
    c.fingerprint_sha256,
761
    c.status AS certificate_status
762
FROM vhosts v
763
JOIN hosts h ON h.fqdn = v.host_fqdn
764
LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
765
WHERE v.status = 'active'
766
ORDER BY v.vhost_fqdn
767
SQL
768
    $sth->execute;
769
    while (my $row = $sth->fetchrow_hashref) {
770
        my $cert_id = clean_scalar($row->{certificate_id} || '');
771
        my %certificate = $cert_id ? (
772
            id => $cert_id,
773
            name => $cert_id,
774
            common_name => clean_scalar($row->{common_name} || ''),
775
            status => clean_scalar($row->{certificate_status} || ''),
776
            not_after => clean_scalar($row->{not_after} || ''),
777
            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
Bogdan Timofte authored 3 days ago
778
            has_private_key => json_bool(ca_private_key_exists($cert_id)),
Bogdan Timofte authored 3 days ago
779
        ) : ();
780
        push @rows, {
781
            vhost => $row->{vhost_fqdn},
782
            vhost_fqdn => $row->{vhost_fqdn},
783
            host_id => $row->{legacy_id} || '',
784
            host_fqdn => $row->{host_fqdn},
785
            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
786
            monitoring => $row->{monitoring} || '',
787
            status => $row->{host_status} || $row->{vhost_status} || '',
788
            vhost_status => $row->{vhost_status} || '',
789
            certificate_id => $cert_id,
790
            certificate => $cert_id ? \%certificate : undef,
791
        };
792
    }
793
    return @rows;
794
}
795

            
796
sub certificate_payloads {
797
    my ($dbh) = @_;
798
    my @certificates;
799
    my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
800
    $sth->execute('retired');
801
    while (my $row = $sth->fetchrow_hashref) {
802
        my $id = clean_scalar($row->{certificate_id} || '');
803
        next unless $id;
804
        push @certificates, {
805
            id => $id,
806
            name => $id,
807
            host_fqdn => $row->{host_fqdn} || '',
808
            common_name => $row->{common_name} || '',
809
            subject => $row->{subject} || '',
810
            issuer => $row->{issuer} || '',
811
            serial => $row->{serial} || '',
812
            status => $row->{status} || '',
813
            not_before => $row->{not_before} || '',
814
            not_after => $row->{not_after} || '',
815
            fingerprint_sha256 => $row->{fingerprint_sha256} || '',
816
            dns_names => [ certificate_dns_names($dbh, $id) ],
Bogdan Timofte authored 3 days ago
817
            has_private_key => json_bool(ca_private_key_exists($id)),
Bogdan Timofte authored 3 days ago
818
        };
819
    }
820
    return @certificates;
821
}
822

            
823
sub certificate_dns_names {
824
    my ($dbh, $certificate_id) = @_;
825
    my @names;
826
    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
827
    $sth->execute($certificate_id);
828
    while (my ($name) = $sth->fetchrow_array) {
829
        push @names, $name;
830
    }
831
    return @names;
832
}
833

            
Xdev Host Manager authored a week ago
834
sub upsert_host {
835
    my ($client, $payload) = @_;
Bogdan Timofte authored 4 days ago
836
    my $ip = canonical_ip($payload);
837
    return send_json($client, 400, { error => 'missing_ip' }) unless $ip;
Xdev Host Manager authored a week ago
838

            
Bogdan Timofte authored 4 days ago
839
    my $fqdn = canonical_host_fqdn($payload);
840
    return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
Bogdan Timofte authored 2 days ago
841
    my $id = clean_id($payload->{id} || '');
842
    $id = clean_id($fqdn) unless length $id;
Bogdan Timofte authored 4 days ago
843
    my @aliases = clean_alias_names($payload);
Xdev Host Manager authored a week ago
844

            
845
    my $registry = load_registry();
Bogdan Timofte authored 2 days ago
846
    my ($existing_host) = grep {
Bogdan Timofte authored 2 days ago
847
        (($_->{fqdn} || '') eq $fqdn) || (($_->{id} || '') eq $id)
Bogdan Timofte authored 2 days ago
848
    } @{ $registry->{hosts} || [] };
Bogdan Timofte authored 2 days ago
849
    $id = clean_id($existing_host->{id} || $fqdn) if $existing_host;
Bogdan Timofte authored 4 days ago
850
    my @vhosts = defined $payload->{vhosts}
851
        ? clean_vhost_names($payload)
852
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
853
    my %host = (
854
        id => $id,
Bogdan Timofte authored 4 days ago
855
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
856
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
857
        ip => $ip,
858
        aliases => \@aliases,
859
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
860
        roles => [ clean_list($payload->{roles}) ],
Bogdan Timofte authored a day ago
861
        tags => [ clean_tag_labels($payload->{tags}) ],
Xdev Host Manager authored a week ago
862
        sources => [ clean_list($payload->{sources}) ],
863
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
864
        notes => clean_scalar($payload->{notes} || ''),
865
    );
866

            
Bogdan Timofte authored 4 days ago
867
    my $response = eval {
868
        my $replaced = 0;
869
        for my $i (0 .. $#{ $registry->{hosts} }) {
Bogdan Timofte authored 2 days ago
870
            if (($registry->{hosts}->[$i]{id} || '') eq $id || ($registry->{hosts}->[$i]{fqdn} || '') eq $fqdn) {
Bogdan Timofte authored 4 days ago
871
                $registry->{hosts}->[$i] = \%host;
872
                $replaced = 1;
873
                last;
874
            }
Xdev Host Manager authored a week ago
875
        }
Bogdan Timofte authored 4 days ago
876
        push @{ $registry->{hosts} }, \%host unless $replaced;
877
        save_registry($registry);
878
        1;
879
    };
880
    if (!$response) {
881
        my $err = $@ || 'upsert_failed';
882
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
883
            if $err =~ /alias_conflict:/;
884
        die $err;
Xdev Host Manager authored a week ago
885
    }
886
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
887
}
888

            
889
sub delete_host {
Bogdan Timofte authored a day ago
890
    my ($client, $target) = @_;
891
    my $fqdn = normalize_dns_name($target || '');
892
    my $id = clean_id($target || '');
893
    return send_json($client, 400, { error => 'invalid_host' }) unless $fqdn || $id;
Xdev Host Manager authored a week ago
894

            
895
    my $registry = load_registry();
Bogdan Timofte authored a day ago
896
    my @kept = grep {
897
        (canonical_host_fqdn($_) ne $fqdn) && (($_->{id} || '') ne $id)
898
    } @{ $registry->{hosts} };
Xdev Host Manager authored a week ago
899
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
900
    $registry->{hosts} = \@kept;
901
    save_registry($registry);
902
    return send_json($client, 200, { ok => json_bool(1) });
903
}
904

            
Bogdan Timofte authored 4 days ago
905
sub reassign_vhost {
906
    my ($client, $payload) = @_;
907
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
908
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
Bogdan Timofte authored 3 days ago
909
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
910
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
911

            
912
    my $dbh = dbh();
913
    my ($current_fqdn) = $dbh->selectrow_array(
914
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
915
        undef,
916
        $vhost,
917
    );
918
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
919
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
920
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
921

            
922
    my $result = eval {
923
        with_transaction($dbh, sub {
924
            my $now = iso_now();
925
            $dbh->do(
926
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
927
                undef,
928
                $target_fqdn, $now, $vhost,
929
            );
930

            
931
            my $registry = load_registry_from_db();
932
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
933
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
934

            
935
            upsert_host_to_db($dbh, $target_host) if $target_host;
936
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
937
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
938
        });
939
        1;
940
    };
941
    if (!$result) {
942
        my $err = $@ || 'vhost_reassign_failed';
943
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
944
    }
Bogdan Timofte authored 2 days ago
945
    my $publish = publish_dns_change(load_registry(), 'vhost-reassign');
946
    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
947
}
948

            
Bogdan Timofte authored 4 days ago
949
sub upsert_vhost {
950
    my ($client, $payload) = @_;
951
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
952
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
Bogdan Timofte authored 3 days ago
953
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
954
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
955

            
956
    my $dbh = dbh();
957
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
Bogdan Timofte authored 3 days ago
958
    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
959
    my ($current_fqdn) = $dbh->selectrow_array(
960
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
961
        undef,
962
        $vhost,
963
    );
964

            
965
    my $result = eval {
966
        with_transaction($dbh, sub {
967
            my $now = iso_now();
968
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
969

            
970
            my $registry = load_registry_from_db();
971
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
972
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
973

            
974
            upsert_host_to_db($dbh, $target_host) if $target_host;
975
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
976
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
977
        });
978
        1;
979
    };
980
    if (!$result) {
981
        my $err = $@ || 'vhost_upsert_failed';
982
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
983
    }
Bogdan Timofte authored 2 days ago
984
    my $publish = publish_dns_change(load_registry(), 'vhost-upsert');
985
    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
986
}
987

            
988
sub delete_vhost {
989
    my ($client, $payload) = @_;
990
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
991
    my $confirm = normalize_dns_name($payload->{confirm} || '');
Bogdan Timofte authored 3 days ago
992
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 4 days ago
993
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
994

            
995
    my $dbh = dbh();
996
    my ($current_fqdn) = $dbh->selectrow_array(
997
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
998
        undef,
999
        $vhost,
1000
    );
1001
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
1002

            
1003
    my $result = eval {
1004
        with_transaction($dbh, sub {
1005
            my $now = iso_now();
1006
            $dbh->do(
1007
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
1008
                undef,
1009
                $now, $vhost,
1010
            );
1011

            
1012
            my $registry = load_registry_from_db();
1013
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
1014
            upsert_host_to_db($dbh, $current_host) if $current_host;
1015
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
1016
        });
1017
        1;
1018
    };
1019
    if (!$result) {
1020
        my $err = $@ || 'vhost_delete_failed';
1021
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
1022
    }
Bogdan Timofte authored 2 days ago
1023
    my $publish = publish_dns_change(load_registry(), 'vhost-delete');
1024
    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
1025
}
1026

            
Bogdan Timofte authored 3 days ago
1027
sub set_host_certificate {
1028
    my ($client, $payload) = @_;
1029
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
1030
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
1031
    my $certificate_id = clean_certificate_id($raw_certificate_id);
1032
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
1033
    return send_json($client, 400, { error => 'invalid_certificate' })
1034
        if length($raw_certificate_id) && !length($certificate_id);
1035

            
1036
    my $dbh = dbh();
1037
    return send_json($client, 404, { error => 'host_not_found' })
1038
        unless db_scalar($dbh, "SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status = 'active'", $host_fqdn);
1039
    if (length $certificate_id) {
1040
        return send_json($client, 400, { error => 'invalid_certificate' })
1041
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
1042
    }
1043

            
1044
    my $now = iso_now();
1045
    with_transaction($dbh, sub {
1046
        upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
1047
        set_schema_meta($dbh, 'registry_updated_at', $now);
1048
    });
1049
    return send_json($client, 200, { ok => json_bool(1), host_fqdn => $host_fqdn, certificate_id => $certificate_id });
1050
}
1051

            
1052
sub issue_host_certificate {
1053
    my ($client, $payload) = @_;
1054
    my $host_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
1055
    return send_json($client, 400, { error => 'invalid_host' }) unless $host_fqdn;
1056

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

            
1061
    my @dns_names = unique_preserve(grep { length $_ } (
1062
        $host_fqdn,
1063
        declared_alias_names($host),
1064
        derived_alias_names($host),
1065
    ));
1066
    my $certificate_id = clean_certificate_id($host_fqdn . '-' . strftime('%Y%m%d%H%M%S', localtime));
1067
    my $dbh = dbh();
1068
    my $issued = eval {
1069
        ca_manager_output('issue', $certificate_id, @dns_names);
1070
        ca_manager_json('list-json');
1071
        with_transaction($dbh, sub {
1072
            my $now = iso_now();
1073
            upsert_host_tls_row($dbh, $host_fqdn, $certificate_id, $now);
1074
            set_schema_meta($dbh, 'registry_updated_at', $now);
1075
        });
1076
        1;
1077
    };
1078
    if (!$issued) {
1079
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
1080
    }
1081

            
1082
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
1083
    return send_json($client, 200, {
1084
        ok => json_bool(1),
1085
        host_fqdn => $host_fqdn,
1086
        certificate_id => $certificate_id,
1087
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
1088
    });
1089
}
1090

            
Bogdan Timofte authored 3 days ago
1091
sub set_vhost_certificate {
1092
    my ($client, $payload) = @_;
1093
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
1094
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
1095
    my $certificate_id = clean_certificate_id($raw_certificate_id);
Bogdan Timofte authored 3 days ago
1096
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 3 days ago
1097
    return send_json($client, 400, { error => 'invalid_certificate' })
1098
        if length($raw_certificate_id) && !length($certificate_id);
1099

            
1100
    my $dbh = dbh();
1101
    return send_json($client, 404, { error => 'vhost_not_found' })
1102
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
1103
    if (length $certificate_id) {
1104
        return send_json($client, 400, { error => 'invalid_certificate' })
1105
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
1106
    }
1107

            
1108
    my $now = iso_now();
1109
    $dbh->do(
1110
        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
1111
        undef,
1112
        length($certificate_id) ? $certificate_id : undef,
1113
        length($certificate_id) ? 'local-ca' : 'none',
1114
        $now,
1115
        $vhost,
1116
        'active',
1117
    );
1118
    set_schema_meta($dbh, 'registry_updated_at', $now);
1119
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
1120
}
1121

            
1122
sub issue_vhost_certificate {
1123
    my ($client, $payload) = @_;
1124
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
Bogdan Timofte authored 3 days ago
1125
    return send_json($client, 400, { error => 'invalid_vhost' }) unless vhost_name_is_valid($vhost);
Bogdan Timofte authored 3 days ago
1126

            
1127
    my $dbh = dbh();
1128
    my ($host_fqdn) = $dbh->selectrow_array(
1129
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
1130
        undef,
1131
        $vhost,
1132
    );
1133
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
1134

            
1135
    my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
1136
    my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
1137
    my $issued = eval {
1138
        ca_manager_output('issue', $certificate_id, @dns_names);
1139
        ca_manager_json('list-json');
1140
        with_transaction($dbh, sub {
1141
            my $now = iso_now();
1142
            $dbh->do(
1143
                "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
1144
                undef,
1145
                $certificate_id,
1146
                $now,
1147
                $vhost,
1148
            );
1149
            set_schema_meta($dbh, 'registry_updated_at', $now);
1150
        });
1151
        1;
1152
    };
1153
    if (!$issued) {
1154
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
1155
    }
1156

            
1157
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
1158
    return send_json($client, 200, {
1159
        ok => json_bool(1),
1160
        vhost_fqdn => $vhost,
1161
        host_fqdn => $host_fqdn,
1162
        certificate_id => $certificate_id,
1163
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
1164
    });
1165
}
1166

            
Xdev Host Manager authored a week ago
1167
sub analyze_hosts {
1168
    my ($hosts) = @_;
1169
    my @problems;
1170
    my (%names, %ids);
1171
    for my $host (@$hosts) {
1172
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
1173
        my $fqdn = canonical_host_fqdn($host);
1174
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
1175
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
1176
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
1177
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
1178
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
1179
            if grep { /^(is|vad|b)-/ } @declared;
1180
        for my $name (@declared) {
Xdev Host Manager authored a week ago
1181
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
1182
        }
Bogdan Timofte authored 4 days ago
1183
        my %declared = map { $_ => 1 } @declared;
1184
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
1185
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
1186
                if $declared{$derived};
1187
        }
Bogdan Timofte authored 4 days ago
1188
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
1189
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
1190
    }
1191
    return \@problems;
1192
}
1193

            
Xdev Host Manager authored a week ago
1194
sub host_payload {
Bogdan Timofte authored a day ago
1195
    my ($host, $tls, $tag_catalog) = @_;
1196
    $tls ||= {};
1197
    $tag_catalog ||= {};
Xdev Host Manager authored a week ago
1198
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
1199
    $copy{fqdn} = canonical_host_fqdn($host);
1200
    $copy{ip} = canonical_ip($host);
1201
    $copy{aliases} = [ declared_alias_names($host) ];
1202
    $copy{derived_aliases} = [ derived_alias_names($host) ];
1203
    $copy{vhosts} = [ declared_vhost_names($host) ];
1204
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Bogdan Timofte authored a day ago
1205
    $copy{tags} = [ clean_tag_labels($host->{tags}) ];
1206
    $copy{tag_details} = [
1207
        map { $tag_catalog->{$_} || default_tag_payload($_) } @{ $copy{tags} }
1208
    ];
Bogdan Timofte authored 3 days ago
1209
    $copy{certificate_id} = clean_scalar($tls->{certificate_id} || '');
1210
    $copy{certificate} = $tls->{certificate} if $tls && ref($tls->{certificate}) eq 'HASH';
Xdev Host Manager authored a week ago
1211
    return \%copy;
1212
}
1213

            
1214
sub effective_names {
1215
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
1216
    my @names = declared_dns_names($host);
1217
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
1218
    return unique_preserve(@names);
1219
}
1220

            
Bogdan Timofte authored 3 days ago
1221
sub host_dns_names {
1222
    my ($host) = @_;
1223
    my @names;
1224
    my $fqdn = canonical_host_fqdn($host);
1225
    push @names, $fqdn if length $fqdn;
1226
    push @names, declared_alias_names($host), derived_alias_names($host);
1227
    return unique_preserve(@names);
1228
}
1229

            
1230
sub vhost_cname_records {
1231
    my ($host) = @_;
1232
    my $target = canonical_host_fqdn($host);
1233
    return () unless length $target;
1234
    my @records;
1235
    for my $vhost (declared_vhost_names($host)) {
1236
        push @records, [ $vhost, $target ];
1237
        if (my $short = short_alias_for_fqdn($vhost)) {
1238
            push @records, [ $short, $target ];
1239
        }
1240
    }
1241
    my %seen;
1242
    return grep { !$seen{$_->[0]}++ } @records;
1243
}
1244

            
Bogdan Timofte authored 4 days ago
1245
sub declared_dns_names {
1246
    my ($host) = @_;
1247
    my @names;
1248
    my $fqdn = canonical_host_fqdn($host);
1249
    push @names, $fqdn if length $fqdn;
1250
    push @names, declared_alias_names($host);
1251
    push @names, declared_vhost_names($host);
1252
    return unique_preserve(@names);
1253
}
1254

            
1255
sub declared_alias_names {
1256
    my ($host) = @_;
1257
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
1258
}
1259

            
1260
sub declared_vhost_names {
1261
    my ($host) = @_;
1262
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
1263
}
1264

            
1265
sub declared_dns_names_legacy {
1266
    my ($host) = @_;
1267
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
1268
}
1269

            
1270
sub split_legacy_names {
1271
    my ($id, $names) = @_;
1272
    my $fallback = clean_id($id || '');
1273
    my (%result) = (
1274
        fqdn => '',
1275
        aliases => [],
1276
        vhosts => [],
1277
    );
1278
    for my $name (map { normalize_dns_name($_) } @$names) {
1279
        next unless length $name;
1280
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
1281
            $result{fqdn} = $name;
1282
            next;
1283
        }
1284
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
1285
            $result{fqdn} = $name;
1286
            next;
1287
        }
1288
        if (name_is_vhost($name)) {
1289
            push @{ $result{vhosts} }, $name;
1290
        } else {
1291
            push @{ $result{aliases} }, $name;
1292
        }
1293
    }
1294
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
1295
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
1296
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
1297
    return \%result;
1298
}
1299

            
1300
sub derived_alias_names {
Xdev Host Manager authored a week ago
1301
    my ($host) = @_;
1302
    my @derived;
Bogdan Timofte authored 4 days ago
1303
    my $fqdn = canonical_host_fqdn($host);
1304
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
1305
    for my $name (declared_alias_names($host)) {
1306
        push @derived, short_alias_for_fqdn($name);
1307
    }
1308
    return unique_preserve(grep { length $_ } @derived);
1309
}
1310

            
1311
sub derived_vhost_alias_names {
1312
    my ($host) = @_;
1313
    my @derived;
1314
    for my $name (declared_vhost_names($host)) {
1315
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
1316
    }
Bogdan Timofte authored 4 days ago
1317
    return unique_preserve(grep { length $_ } @derived);
1318
}
1319

            
1320
sub clean_alias_names {
1321
    my ($payload) = @_;
1322
    return clean_name_bucket($payload->{aliases})
1323
        if defined $payload->{aliases};
1324
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1325
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
1326
}
1327

            
1328
sub clean_vhost_names {
1329
    my ($payload) = @_;
1330
    return clean_name_bucket($payload->{vhosts})
1331
        if defined $payload->{vhosts};
1332
    my @legacy = remove_derived_names(clean_list($payload->{names}));
1333
    return grep { name_is_vhost($_) } @legacy;
1334
}
1335

            
1336
sub clean_name_bucket {
1337
    my ($value) = @_;
1338
    my @names = clean_list($value);
1339
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
1340
}
1341

            
1342
sub remove_derived_names {
1343
    my @names = @_;
1344
    my %derived;
1345
    for my $name (@names) {
1346
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
1347
        $derived{$1} = 1;
1348
    }
1349
    return grep { !$derived{$_} } @names;
1350
}
1351

            
1352
sub unique_preserve {
1353
    my @values = @_;
1354
    my %seen;
1355
    return grep { !$seen{$_}++ } @values;
1356
}
1357

            
Bogdan Timofte authored 4 days ago
1358
sub canonical_ip {
1359
    my ($host) = @_;
1360
    return '' unless $host && ref($host) eq 'HASH';
1361
    for my $key (qw(ip dns_ip hosts_ip)) {
1362
        my $value = clean_scalar($host->{$key} || '');
1363
        return $value if length $value;
1364
    }
1365
    return '';
1366
}
1367

            
Xdev Host Manager authored a week ago
1368
sub problem {
1369
    my ($host, $code, $message) = @_;
1370
    return { host_id => $host->{id}, code => $code, message => $message };
1371
}
1372

            
Bogdan Timofte authored a day ago
1373
sub render_resolver_records {
Xdev Host Manager authored a week ago
1374
    my ($registry) = @_;
Bogdan Timofte authored a day ago
1375
    my $out = "# Ephemeral resolver records for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
1376
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Bogdan Timofte authored a day ago
1377
    $out .= "# hosts.yaml is the finished export; this stream is consumed by resolver sync only.\n";
Xdev Host Manager authored a week ago
1378
    $out .= "#\n";
1379
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
1380
    $out .= "# ip<TAB>name [aliases...]\n";
Bogdan Timofte authored 3 days ago
1381
    $out .= "# CNAME<TAB>alias<TAB>target\n";
Xdev Host Manager authored a week ago
1382
    $out .= "#\n";
1383
    $out .= "# Priority rule:\n";
1384
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
1385
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
1386
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
1387
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1388
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
1389
        my $ip = canonical_ip($host);
1390
        next unless $ip;
Bogdan Timofte authored 3 days ago
1391
        my @names = host_dns_names($host);
Xdev Host Manager authored a week ago
1392
        next unless @names;
Bogdan Timofte authored 4 days ago
1393
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Bogdan Timofte authored 3 days ago
1394
        for my $record (vhost_cname_records($host)) {
1395
            $out .= join("\t", 'CNAME', @$record) . "\n";
1396
        }
Xdev Host Manager authored a week ago
1397
    }
1398
    return $out;
1399
}
1400

            
1401
sub render_monitoring {
1402
    my ($registry) = @_;
1403
    my @hosts;
1404
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
1405
        next unless ($host->{status} || 'active') eq 'active';
1406
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
1407
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
1408
        push @hosts, {
1409
            id => $host->{id},
Xdev Host Manager authored a week ago
1410
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
1411
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
1412
            aliases => \@names,
Bogdan Timofte authored 4 days ago
1413
            fqdn => canonical_host_fqdn($host),
1414
            declared_names => [ declared_dns_names($host) ],
1415
            aliases_declared => [ declared_alias_names($host) ],
1416
            aliases_derived => [ derived_alias_names($host) ],
1417
            vhosts_declared => [ declared_vhost_names($host) ],
1418
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
1419
            roles => [ @{ $host->{roles} || [] } ],
Bogdan Timofte authored a day ago
1420
            tags => [ clean_tag_labels($host->{tags}) ],
Xdev Host Manager authored a week ago
1421
            monitoring => $host->{monitoring} || 'pending',
1422
            notes => $host->{notes} || '',
1423
        };
1424
    }
1425
    return {
1426
        version => $registry->{version},
1427
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
1428
        source => $opt{db},
Xdev Host Manager authored a week ago
1429
        hosts => \@hosts,
1430
    };
1431
}
1432

            
Bogdan Timofte authored 4 days ago
1433
sub debug_database_tables_payload {
1434
    my $dbh = dbh();
1435
    my @tables;
1436
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
1437
    $sth->execute;
1438
    while (my ($name) = $sth->fetchrow_array) {
1439
        my $quoted = $dbh->quote_identifier($name);
1440
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1441
        push @tables, {
1442
            name => $name,
1443
            rows => int($count || 0),
1444
        };
1445
    }
1446
    return {
1447
        database => $opt{db},
1448
        generated_at => iso_now(),
1449
        tables => \@tables,
1450
        counts => {
1451
            tables => scalar @tables,
1452
            rows => sum(map { $_->{rows} } @tables),
1453
        },
1454
    };
1455
}
1456

            
1457
sub debug_database_table_payload {
1458
    my ($table, $limit) = @_;
1459
    my $dbh = dbh();
1460
    $table = clean_scalar($table);
1461
    return { error => 'missing_table' } unless length $table;
1462
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1463
    $limit = int($limit || 100);
1464
    $limit = 1 if $limit < 1;
1465
    $limit = 500 if $limit > 500;
1466

            
1467
    my $quoted = $dbh->quote_identifier($table);
1468
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1469
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1470
    my @index_details;
1471
    for my $index (@$indexes) {
1472
        my $index_name = $index->{name} || '';
1473
        next unless length $index_name;
1474
        my $quoted_index = $dbh->quote_identifier($index_name);
1475
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1476
        push @index_details, {
1477
            name => $index_name,
1478
            unique => int($index->{unique} || 0),
1479
            origin => $index->{origin} || '',
1480
            partial => int($index->{partial} || 0),
1481
            columns => [ map { $_->{name} || '' } @$index_columns ],
1482
        };
1483
    }
1484
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1485
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1486
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1487

            
1488
    return {
1489
        database => $opt{db},
1490
        table => $table,
1491
        generated_at => iso_now(),
1492
        limit => $limit,
1493
        row_count => int($row_count || 0),
1494
        columns => $columns,
1495
        indexes => \@index_details,
1496
        foreign_keys => $foreign_keys,
1497
        rows => $rows,
1498
    };
1499
}
1500

            
Bogdan Timofte authored 4 days ago
1501
sub debug_database_table_export_payload {
1502
    my ($table) = @_;
1503
    my $dbh = dbh();
1504
    $table = clean_scalar($table);
1505
    return { error => 'missing_table' } unless length $table;
1506
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1507

            
1508
    my $quoted = $dbh->quote_identifier($table);
1509
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1510
    my @column_names = map { $_->{name} || '' } @$columns;
1511
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1512
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1513

            
1514
    return {
1515
        database => $opt{db},
1516
        table => $table,
1517
        generated_at => iso_now(),
1518
        row_count => int($row_count || 0),
1519
        columns => \@column_names,
1520
        rows => $rows,
1521
    };
1522
}
1523

            
1524
sub render_debug_table_csv {
1525
    my ($export) = @_;
1526
    my @columns = @{ $export->{columns} || [] };
1527
    my @lines = (join(',', map { csv_cell($_) } @columns));
1528
    for my $row (@{ $export->{rows} || [] }) {
1529
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1530
    }
1531
    return join("\n", @lines) . "\n";
1532
}
1533

            
1534
sub csv_cell {
1535
    my ($value) = @_;
1536
    $value = '' unless defined $value;
1537
    $value = "$value";
1538
    $value =~ s/"/""/g;
1539
    return qq("$value") if $value =~ /[",\r\n]/;
1540
    return $value;
1541
}
1542

            
1543
sub debug_table_export_filename {
1544
    my ($table, $extension) = @_;
1545
    $table = clean_scalar($table || 'table');
1546
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1547
    $table = 'table' unless length $table;
1548
    return "debug-$table.$extension";
1549
}
1550

            
Bogdan Timofte authored 4 days ago
1551
sub debug_table_exists {
1552
    my ($dbh, $table) = @_;
1553
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1554
    my ($exists) = $dbh->selectrow_array(
1555
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1556
        undef,
1557
        $table,
1558
    );
1559
    return $exists ? 1 : 0;
1560
}
1561

            
1562
sub sum {
1563
    my $total = 0;
1564
    $total += $_ || 0 for @_;
1565
    return $total;
1566
}
1567

            
Xdev Host Manager authored a week ago
1568
sub ca_script_path {
1569
    return "$project_dir/scripts/ca_manager.sh";
1570
}
1571

            
1572
sub ca_dir {
1573
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1574
}
1575

            
1576
sub ca_cert_path {
1577
    return ca_dir() . "/certs/ca.cert.pem";
1578
}
1579

            
Bogdan Timofte authored 5 days ago
1580
sub ca_issued_cert_path {
1581
    my ($name) = @_;
1582
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1583
    return ca_dir() . "/issued/$name.cert.pem";
1584
}
1585

            
Bogdan Timofte authored 3 days ago
1586
sub ca_issued_key_path {
1587
    my ($name) = @_;
1588
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1589
    return ca_dir() . "/issued/$name.key.pem";
1590
}
1591

            
Bogdan Timofte authored 3 days ago
1592
sub ca_private_key_exists {
1593
    my ($name) = @_;
1594
    return 0 unless clean_certificate_id($name || '');
1595
    return -f ca_issued_key_path($name) ? 1 : 0;
1596
}
1597

            
Bogdan Timofte authored 3 days ago
1598
sub ca_manager_output {
1599
    my (@args) = @_;
Xdev Host Manager authored a week ago
1600
    my $script = ca_script_path();
1601
    die "CA manager script is missing\n" unless -x $script;
1602
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 3 days ago
1603
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1604
    local $/;
1605
    my $out = <$fh>;
1606
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 3 days ago
1607
    return $out || '';
1608
}
1609

            
1610
sub ca_manager_json {
1611
    my ($command) = @_;
1612
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1613
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1614
    sync_certificates_from_json($out) if $command eq 'list-json';
1615
    return $out;
1616
}
1617

            
1618
sub sync_certificates_from_json {
1619
    my ($json) = @_;
1620
    my $certs = eval { json_decode($json || '[]') };
1621
    return if $@ || ref($certs) ne 'ARRAY';
1622
    my $dbh = dbh();
1623
    my $now = iso_now();
1624
    with_transaction($dbh, sub {
1625
        for my $cert (@$certs) {
1626
            next unless ref($cert) eq 'HASH';
1627
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1628
            next unless $name;
1629
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1630
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1631
            my $cert_path = ca_issued_cert_path($name);
1632
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1633
            my $serial = clean_scalar($cert->{serial} || '');
1634
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1635
            $dbh->do(
1636
                '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) '
1637
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1638
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1639
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1640
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1641
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1642
                undef,
1643
                $name,
1644
                $host_fqdn || undef,
1645
                $dns_names[0] || '',
1646
                clean_scalar($cert->{subject} || ''),
1647
                clean_scalar($cert->{issuer} || ''),
1648
                length($serial) ? $serial : undef,
1649
                clean_scalar($cert->{not_before} || ''),
1650
                clean_scalar($cert->{not_after} || ''),
1651
                length($fingerprint) ? $fingerprint : undef,
1652
                $cert_path,
1653
                $csr_path,
1654
                $now,
1655
                $now,
1656
            );
1657
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1658
            for my $dns_name (@dns_names) {
1659
                next unless length $dns_name;
1660
                $dbh->do(
1661
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1662
                    undef,
1663
                    $name,
1664
                    $dns_name,
1665
                );
1666
            }
1667
        }
1668
    });
1669
}
1670

            
1671
sub infer_certificate_host_fqdn {
1672
    my ($dbh, $dns_names) = @_;
1673
    for my $name (@$dns_names) {
1674
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1675
        return $fqdn if $fqdn;
1676
    }
1677
    for my $name (@$dns_names) {
1678
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1679
        return $fqdn if $fqdn;
1680
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1681
        return $fqdn if $fqdn;
1682
    }
1683
    return '';
Xdev Host Manager authored a week ago
1684
}
1685

            
Xdev Host Manager authored a week ago
1686
sub parse_hosts_yaml {
1687
    my ($text) = @_;
1688
    my %registry = (
1689
        version => 1,
1690
        updated_at => '',
1691
        policy => {},
1692
        hosts => [],
1693
    );
1694
    my ($section, $current, $list_key);
1695
    for my $line (split /\n/, $text) {
1696
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1697
        if ($line =~ /^version:\s*(\d+)/) {
1698
            $registry{version} = int($1);
1699
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1700
            $registry{updated_at} = yaml_unquote($1);
1701
        } elsif ($line =~ /^policy:\s*$/) {
1702
            $section = 'policy';
1703
        } elsif ($line =~ /^hosts:\s*$/) {
1704
            $section = 'hosts';
1705
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1706
            $registry{policy}{$1} = yaml_unquote($2);
Bogdan Timofte authored a day ago
1707
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - (id|fqdn):\s*(.+)$/) {
Xdev Host Manager authored a week ago
1708
            $current = {
Bogdan Timofte authored a day ago
1709
                id => '',
Bogdan Timofte authored 4 days ago
1710
                fqdn => '',
Xdev Host Manager authored a week ago
1711
                status => 'active',
Bogdan Timofte authored 4 days ago
1712
                ip => '',
1713
                aliases => [],
1714
                vhosts => [],
Xdev Host Manager authored a week ago
1715
                roles => [],
Bogdan Timofte authored a day ago
1716
                tags => [],
Xdev Host Manager authored a week ago
1717
                sources => [],
1718
                monitoring => 'pending',
1719
                notes => '',
1720
            };
Bogdan Timofte authored a day ago
1721
            if ($1 eq 'id') {
1722
                $current->{id} = yaml_unquote($2);
1723
            } else {
1724
                $current->{fqdn} = normalize_dns_name(yaml_unquote($2));
1725
                $current->{id} = legacy_id_from_fqdn($current->{fqdn});
1726
            }
Xdev Host Manager authored a week ago
1727
            push @{ $registry{hosts} }, $current;
1728
            $list_key = undef;
1729
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1730
            $list_key = $1;
1731
            $current->{$list_key} ||= [];
1732
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1733
            push @{ $current->{$list_key} }, yaml_unquote($1);
1734
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1735
            my $key = $1;
1736
            my $value = yaml_unquote($2);
1737
            if ($key eq 'ip') {
1738
                $current->{ip} = $value;
1739
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1740
                $current->{ip} ||= $value;
1741
            } elsif ($key eq 'fqdn') {
1742
                $current->{fqdn} = normalize_dns_name($value);
1743
            } elsif ($key eq 'names') {
1744
                # ignored here; legacy list is handled after parsing
1745
            } else {
1746
                $current->{$key} = $value;
1747
            }
Xdev Host Manager authored a week ago
1748
            $list_key = undef;
1749
        }
1750
    }
Bogdan Timofte authored 4 days ago
1751
    for my $host (@{ $registry{hosts} }) {
1752
        my @legacy_names = @{ $host->{names} || [] };
1753
        if (@legacy_names) {
1754
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1755
            $host->{fqdn} ||= $legacy->{fqdn};
1756
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1757
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1758
        }
1759
        delete $host->{names};
1760
        $host->{fqdn} ||= canonical_host_fqdn($host);
Bogdan Timofte authored a day ago
1761
        $host->{id} ||= legacy_id_from_fqdn($host->{fqdn});
Bogdan Timofte authored 4 days ago
1762
    }
Xdev Host Manager authored a week ago
1763
    return \%registry;
1764
}
1765

            
1766
sub render_hosts_yaml {
1767
    my ($registry) = @_;
1768
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1769
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1770
    $out .= "policy:\n";
1771
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1772
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1773
    }
1774
    $out .= "hosts:\n";
Bogdan Timofte authored a day ago
1775
    for my $host (sort { canonical_host_fqdn($a) cmp canonical_host_fqdn($b) } @{ $registry->{hosts} || [] }) {
1776
        $out .= "  - fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
Bogdan Timofte authored 4 days ago
1777
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1778
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
Bogdan Timofte authored a day ago
1779
        for my $key (qw(aliases vhosts roles tags)) {
Xdev Host Manager authored a week ago
1780
            $out .= "    $key:\n";
1781
            for my $value (@{ $host->{$key} || [] }) {
1782
                $out .= "      - " . yq($value) . "\n";
1783
            }
1784
        }
1785
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1786
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1787
    }
1788
    return $out;
1789
}
1790

            
Xdev Host Manager authored a week ago
1791
sub parse_work_orders_yaml {
1792
    my ($text) = @_;
1793
    my %orders = (
1794
        version => 1,
1795
        work_orders => [],
1796
    );
Xdev Host Manager authored a week ago
1797
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1798
    for my $line (split /\n/, $text) {
1799
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1800
        if ($line =~ /^version:\s*(\d+)/) {
1801
            $orders{version} = int($1);
1802
        } elsif ($line =~ /^work_orders:\s*$/) {
1803
            $section = 'work_orders';
1804
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1805
            $current = {
1806
                id => yaml_unquote($1),
1807
                status => 'pending',
Xdev Host Manager authored a week ago
1808
                checklist => [],
Xdev Host Manager authored a week ago
1809
                actions => [],
1810
            };
1811
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1812
            $list_section = '';
Xdev Host Manager authored a week ago
1813
            $current_action = undef;
Xdev Host Manager authored a week ago
1814
            $current_item = undef;
1815
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1816
            $list_section = 'checklist';
1817
            $current->{checklist} ||= [];
1818
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1819
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1820
            push @{ $current->{checklist} }, $current_item;
1821
            $current_action = undef;
1822
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1823
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1824
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1825
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1826
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1827
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1828
            $current_action = { type => yaml_unquote($1) };
1829
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1830
            $current_item = undef;
1831
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1832
            $current_action->{$1} = yaml_unquote($2);
1833
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1834
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1835
            $list_section = '';
Xdev Host Manager authored a week ago
1836
            $current_action = undef;
Xdev Host Manager authored a week ago
1837
            $current_item = undef;
Xdev Host Manager authored a week ago
1838
        }
1839
    }
1840
    return \%orders;
1841
}
1842

            
1843
sub render_work_orders_yaml {
1844
    my ($orders) = @_;
1845
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1846
    $out .= "work_orders:\n";
1847
    for my $wo (@{ $orders->{work_orders} || [] }) {
1848
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1849
        for my $key (qw(status title reason created_at confirmed_at result)) {
1850
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1851
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1852
        }
Xdev Host Manager authored a week ago
1853
        $out .= "    checklist:\n";
1854
        for my $item (@{ $wo->{checklist} || [] }) {
1855
            $out .= "      - id: " . yq($item->{id}) . "\n";
1856
            for my $key (qw(text status owner notes updated_at)) {
1857
                next unless exists $item->{$key} && length($item->{$key} || '');
1858
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1859
            }
1860
        }
Xdev Host Manager authored a week ago
1861
        $out .= "    actions:\n";
1862
        for my $action (@{ $wo->{actions} || [] }) {
1863
            $out .= "      - type: " . yq($action->{type}) . "\n";
1864
            for my $key (qw(host_id name)) {
1865
                next unless exists $action->{$key} && length($action->{$key} || '');
1866
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1867
            }
1868
        }
1869
    }
1870
    return $out;
1871
}
1872

            
Xdev Host Manager authored a week ago
1873
sub request_payload {
1874
    my ($headers, $body) = @_;
1875
    my $type = $headers->{'content-type'} || '';
1876
    if ($type =~ m{application/json}) {
1877
        return json_decode($body || '{}');
1878
    }
1879
    return { parse_params($body || '') };
1880
}
1881

            
1882
sub json_bool {
1883
    my ($value) = @_;
1884
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1885
}
1886

            
1887
sub json_encode {
1888
    my ($value) = @_;
1889
    if (!defined $value) {
1890
        return 'null';
1891
    }
1892
    my $ref = ref($value);
1893
    if (!$ref) {
1894
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1895
        return json_string($value);
1896
    }
1897
    if ($ref eq 'HostManager::JSONBool') {
1898
        return $$value ? 'true' : 'false';
1899
    }
1900
    if ($ref eq 'ARRAY') {
1901
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1902
    }
1903
    if ($ref eq 'HASH') {
1904
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1905
    }
1906
    return json_string("$value");
1907
}
1908

            
1909
sub json_string {
1910
    my ($value) = @_;
1911
    $value = '' unless defined $value;
1912
    $value =~ s/\\/\\\\/g;
1913
    $value =~ s/"/\\"/g;
1914
    $value =~ s/\n/\\n/g;
1915
    $value =~ s/\r/\\r/g;
1916
    $value =~ s/\t/\\t/g;
1917
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1918
    return qq("$value");
1919
}
1920

            
1921
sub json_decode {
1922
    my ($text) = @_;
1923
    my $i = 0;
1924
    my $len = length($text);
1925
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1926

            
1927
    $skip_ws = sub {
1928
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1929
    };
1930

            
1931
    $parse_string = sub {
1932
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1933
        $i++;
1934
        my $out = '';
1935
        while ($i < $len) {
1936
            my $ch = substr($text, $i++, 1);
1937
            return $out if $ch eq '"';
1938
            if ($ch eq "\\") {
1939
                die "Bad JSON escape\n" if $i >= $len;
1940
                my $esc = substr($text, $i++, 1);
1941
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1942
                    $out .= $esc;
1943
                } elsif ($esc eq 'b') {
1944
                    $out .= "\b";
1945
                } elsif ($esc eq 'f') {
1946
                    $out .= "\f";
1947
                } elsif ($esc eq 'n') {
1948
                    $out .= "\n";
1949
                } elsif ($esc eq 'r') {
1950
                    $out .= "\r";
1951
                } elsif ($esc eq 't') {
1952
                    $out .= "\t";
1953
                } elsif ($esc eq 'u') {
1954
                    my $hex = substr($text, $i, 4);
1955
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1956
                    $out .= chr(hex($hex));
1957
                    $i += 4;
1958
                } else {
1959
                    die "Bad JSON escape\n";
1960
                }
1961
            } else {
1962
                $out .= $ch;
1963
            }
1964
        }
1965
        die "Unterminated JSON string\n";
1966
    };
1967

            
1968
    $parse_number = sub {
1969
        my $start = $i;
1970
        $i++ if substr($text, $i, 1) eq '-';
1971
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1972
        if ($i < $len && substr($text, $i, 1) eq '.') {
1973
            $i++;
1974
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1975
        }
1976
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1977
            $i++;
1978
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1979
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1980
        }
1981
        return 0 + substr($text, $start, $i - $start);
1982
    };
1983

            
1984
    $parse_array = sub {
1985
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1986
        $i++;
1987
        my @out;
1988
        $skip_ws->();
1989
        if ($i < $len && substr($text, $i, 1) eq ']') {
1990
            $i++;
1991
            return \@out;
1992
        }
1993
        while (1) {
1994
            push @out, $parse_value->();
1995
            $skip_ws->();
1996
            my $ch = substr($text, $i++, 1);
1997
            last if $ch eq ']';
1998
            die "Expected JSON array comma\n" unless $ch eq ',';
1999
        }
2000
        return \@out;
2001
    };
2002

            
2003
    $parse_object = sub {
2004
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
2005
        $i++;
2006
        my %out;
2007
        $skip_ws->();
2008
        if ($i < $len && substr($text, $i, 1) eq '}') {
2009
            $i++;
2010
            return \%out;
2011
        }
2012
        while (1) {
2013
            $skip_ws->();
2014
            my $key = $parse_string->();
2015
            $skip_ws->();
2016
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
2017
            $out{$key} = $parse_value->();
2018
            $skip_ws->();
2019
            my $ch = substr($text, $i++, 1);
2020
            last if $ch eq '}';
2021
            die "Expected JSON object comma\n" unless $ch eq ',';
2022
        }
2023
        return \%out;
2024
    };
2025

            
2026
    $parse_value = sub {
2027
        $skip_ws->();
2028
        die "Unexpected end of JSON\n" if $i >= $len;
2029
        my $ch = substr($text, $i, 1);
2030
        return $parse_string->() if $ch eq '"';
2031
        return $parse_object->() if $ch eq '{';
2032
        return $parse_array->() if $ch eq '[';
2033
        if (substr($text, $i, 4) eq 'true') {
2034
            $i += 4;
2035
            return json_bool(1);
2036
        }
2037
        if (substr($text, $i, 5) eq 'false') {
2038
            $i += 5;
2039
            return json_bool(0);
2040
        }
2041
        if (substr($text, $i, 4) eq 'null') {
2042
            $i += 4;
2043
            return undef;
2044
        }
2045
        return $parse_number->() if $ch =~ /[-0-9]/;
2046
        die "Unexpected JSON token\n";
2047
    };
2048

            
2049
    my $value = $parse_value->();
2050
    $skip_ws->();
2051
    die "Trailing JSON content\n" if $i != $len;
2052
    return $value;
2053
}
2054

            
2055
sub parse_params {
2056
    my ($text) = @_;
2057
    my %out;
2058
    for my $pair (split /&/, $text) {
2059
        next unless length $pair;
2060
        my ($k, $v) = split /=/, $pair, 2;
2061
        $out{url_decode($k)} = url_decode($v || '');
2062
    }
2063
    return %out;
2064
}
2065

            
2066
sub clean_id {
2067
    my ($value) = @_;
2068
    $value = lc clean_scalar($value);
2069
    $value =~ s/[^a-z0-9_.-]+/-/g;
2070
    $value =~ s/^-+|-+$//g;
2071
    return $value;
2072
}
2073

            
Bogdan Timofte authored 3 days ago
2074
sub clean_certificate_id {
2075
    my ($value) = @_;
2076
    $value = clean_scalar($value);
2077
    return '' unless length $value;
2078
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
2079
}
2080

            
Bogdan Timofte authored 3 days ago
2081
sub clean_ip {
2082
    my ($value) = @_;
2083
    $value = clean_scalar($value);
2084
    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/;
2085
    return '';
2086
}
2087

            
2088
sub clean_mac {
2089
    my ($value) = @_;
2090
    $value = lc clean_scalar($value);
2091
    $value =~ s/-/:/g;
2092
    return $value if $value =~ /\A[0-9a-f]{2}(?::[0-9a-f]{2}){5}\z/;
2093
    return '';
2094
}
2095

            
Bogdan Timofte authored a day ago
2096
sub clean_tag_label {
2097
    my ($value) = @_;
2098
    $value = lc clean_scalar($value || '');
2099
    $value =~ s/[^a-z0-9_. -]+/-/g;
2100
    $value =~ s/\s+/-/g;
2101
    $value =~ s/-+/-/g;
2102
    $value =~ s/^-+|-+$//g;
2103
    return $value;
2104
}
2105

            
2106
sub clean_tag_id {
2107
    my ($value) = @_;
2108
    return clean_id(clean_tag_label($value || ''));
2109
}
2110

            
2111
sub clean_tag_labels {
2112
    my ($value) = @_;
2113
    return unique_preserve(grep { length $_ } map { clean_tag_label($_) } clean_list($value));
2114
}
2115

            
2116
sub default_tag_color {
2117
    return '#647084';
2118
}
2119

            
2120
sub default_tag_icon {
2121
    return 'tag';
2122
}
2123

            
2124
sub clean_tag_color {
2125
    my ($value) = @_;
2126
    $value = clean_scalar($value || '');
2127
    return lc $value if $value =~ /\A#[0-9a-fA-F]{6}\z/;
2128
    return default_tag_color();
2129
}
2130

            
2131
sub clean_tag_icon {
2132
    my ($value) = @_;
2133
    $value = lc clean_scalar($value || '');
2134
    $value =~ s/[^a-z0-9_-]+/-/g;
2135
    $value =~ s/^-+|-+$//g;
2136
    return length($value) ? $value : default_tag_icon();
2137
}
2138

            
Bogdan Timofte authored 3 days ago
2139
sub normalize_dhcp_name {
2140
    my ($value) = @_;
2141
    $value = normalize_dns_name($value || '');
2142
    $value =~ s/[^a-z0-9_.-]+/-/g;
2143
    $value =~ s/^-+|-+$//g;
2144
    return $value;
2145
}
2146

            
Xdev Host Manager authored a week ago
2147
sub clean_scalar {
2148
    my ($value) = @_;
2149
    $value = '' unless defined $value;
2150
    $value =~ s/[\r\n\t]+/ /g;
2151
    $value =~ s/^\s+|\s+$//g;
2152
    return $value;
2153
}
2154

            
2155
sub clean_list {
2156
    my ($value) = @_;
2157
    return () unless defined $value;
2158
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
2159
    my @clean;
2160
    for my $item (@items) {
2161
        $item = clean_scalar($item);
2162
        push @clean, $item if length $item;
2163
    }
2164
    return @clean;
2165
}
2166

            
2167
sub yq {
2168
    my ($value) = @_;
2169
    $value = '' unless defined $value;
2170
    $value =~ s/\\/\\\\/g;
2171
    $value =~ s/"/\\"/g;
2172
    return qq("$value");
2173
}
2174

            
2175
sub yaml_unquote {
2176
    my ($value) = @_;
2177
    $value = '' unless defined $value;
2178
    $value =~ s/^\s+|\s+$//g;
2179
    if ($value =~ /^"(.*)"$/) {
2180
        $value = $1;
2181
        $value =~ s/\\"/"/g;
2182
        $value =~ s/\\\\/\\/g;
2183
    }
2184
    return $value;
2185
}
2186

            
2187
sub verify_totp {
2188
    my ($secret, $otp) = @_;
2189
    return 0 unless $secret && $otp =~ /^\d{6}$/;
2190
    my $key = eval { base32_decode($secret) };
2191
    return 0 if $@ || !length $key;
2192
    my $counter = int(time() / 30);
2193
    for my $offset (-1, 0, 1) {
2194
        return 1 if totp_code($key, $counter + $offset) eq $otp;
2195
    }
2196
    return 0;
2197
}
2198

            
2199
sub totp_code {
2200
    my ($key, $counter) = @_;
2201
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
2202
    my $hash = hmac_sha1($msg, $key);
2203
    my $offset = ord(substr($hash, -1)) & 0x0f;
2204
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
2205
    return sprintf('%06d', $bin % 1_000_000);
2206
}
2207

            
2208
sub base32_decode {
2209
    my ($text) = @_;
2210
    $text = uc($text || '');
2211
    $text =~ s/[^A-Z2-7]//g;
2212
    my %map;
2213
    my @chars = ('A'..'Z', '2'..'7');
2214
    @map{@chars} = (0..31);
2215
    my ($bits, $value, $out) = (0, 0, '');
2216
    for my $char (split //, $text) {
2217
        die "Invalid base32\n" unless exists $map{$char};
2218
        $value = ($value << 5) | $map{$char};
2219
        $bits += 5;
2220
        while ($bits >= 8) {
2221
            $bits -= 8;
2222
            $out .= chr(($value >> $bits) & 0xff);
2223
        }
2224
    }
2225
    return $out;
2226
}
2227

            
2228
sub create_session {
2229
    my $nonce = random_hex(24);
2230
    my $expires = int(time() + 8 * 3600);
2231
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
2232
    my $token = "$nonce:$expires:$sig";
2233
    $sessions{$token} = $expires;
2234
    return $token;
2235
}
2236

            
2237
sub is_authenticated {
2238
    my ($headers) = @_;
2239
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2240
    return 0 unless $token;
2241
    my ($nonce, $expires, $sig) = split /:/, $token;
2242
    return 0 unless $nonce && $expires && $sig;
2243
    return 0 if $expires < time();
2244
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
2245
    return exists $sessions{$token};
2246
}
2247

            
2248
sub expire_session {
2249
    my ($headers) = @_;
2250
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
2251
    delete $sessions{$token} if $token;
2252
}
2253

            
2254
sub cookie_value {
2255
    my ($cookie, $name) = @_;
2256
    for my $part (split /;\s*/, $cookie) {
2257
        my ($k, $v) = split /=/, $part, 2;
2258
        return $v if defined $k && $k eq $name;
2259
    }
2260
    return '';
2261
}
2262

            
2263
sub send_json {
2264
    my ($client, $status, $payload, $extra_headers) = @_;
2265
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
2266
}
2267

            
Xdev Host Manager authored a week ago
2268
sub send_json_raw {
2269
    my ($client, $status, $json_body, $extra_headers) = @_;
2270
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
2271
}
2272

            
Xdev Host Manager authored a week ago
2273
sub send_html {
2274
    my ($client, $status, $html) = @_;
2275
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
2276
}
2277

            
2278
sub send_text {
2279
    my ($client, $status, $text) = @_;
2280
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
2281
}
2282

            
2283
sub send_download {
2284
    my ($client, $status, $content, $type, $filename) = @_;
2285
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
2286
}
2287

            
2288
sub send_file {
2289
    my ($client, $path, $type, $filename) = @_;
2290
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
2291
    return send_download($client, 200, read_file($path), $type, $filename);
2292
}
2293

            
2294
sub send_response {
2295
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
2296
    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
2297
    $body = '' unless defined $body;
2298
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
2299
    print $client "Content-Type: $type\r\n";
2300
    print $client "Content-Length: " . length($body) . "\r\n";
2301
    print $client "Cache-Control: no-store\r\n";
2302
    print $client "$_\r\n" for @{ $extra_headers || [] };
2303
    print $client "Connection: close\r\n\r\n";
2304
    print $client $body;
2305
}
2306

            
2307
sub read_file {
2308
    my ($path) = @_;
2309
    open my $fh, '<', $path or die "Cannot read $path: $!";
2310
    local $/;
2311
    return <$fh>;
2312
}
2313

            
2314
sub write_file {
2315
    my ($path, $content) = @_;
2316
    open my $fh, '>', $path or die "Cannot write $path: $!";
2317
    print {$fh} $content;
2318
    close $fh or die "Cannot close $path: $!";
2319
}
2320

            
2321
sub backup_file {
2322
    my ($path) = @_;
2323
    return unless -f $path;
2324
    my $backup_dir = "$project_dir/backups/host-manager";
2325
    make_path($backup_dir) unless -d $backup_dir;
2326
    my $name = $path;
2327
    $name =~ s{.*/}{};
2328
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
2329
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
2330
}
2331

            
Bogdan Timofte authored 2 days ago
2332
sub publish_dns_change {
2333
    my ($registry, $reason) = @_;
2334
    $reason = clean_scalar($reason || 'registry-change');
2335

            
2336
    my $trigger = $opt{dns_publish_trigger} || '';
2337
    return {
2338
        queued => json_bool(0),
Bogdan Timofte authored a day ago
2339
        action => 'resolver-sync',
Bogdan Timofte authored 2 days ago
2340
        reason => $reason,
2341
    } unless length $trigger;
2342

            
2343
    ensure_parent_dir($trigger);
2344
    open my $fh, '>>', $trigger or die "Cannot write DNS publish trigger $trigger: $!";
2345
    print {$fh} iso_now() . "\t$reason\n";
2346
    close $fh or die "Cannot close DNS publish trigger $trigger: $!";
2347

            
2348
    return {
2349
        queued => json_bool(1),
Bogdan Timofte authored a day ago
2350
        action => 'resolver-sync',
Bogdan Timofte authored 2 days ago
2351
        trigger => $trigger,
2352
        reason => $reason,
2353
    };
2354
}
2355

            
Bogdan Timofte authored 4 days ago
2356
my $db_handle;
Bogdan Timofte authored 4 days ago
2357
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
2358

            
2359
sub dbh {
2360
    return $db_handle if $db_handle;
2361
    ensure_parent_dir($opt{db});
2362
    $db_handle = DBI->connect(
2363
        "dbi:SQLite:dbname=$opt{db}",
2364
        '',
2365
        '',
2366
        {
2367
            RaiseError => 1,
2368
            PrintError => 0,
2369
            AutoCommit => 1,
2370
            sqlite_unicode => 1,
2371
        },
2372
    ) or die "Cannot open SQLite database $opt{db}\n";
2373
    $db_handle->do('PRAGMA journal_mode = WAL');
2374
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
2375
    create_database_schema($db_handle);
2376
    seed_database($db_handle) unless $db_seeded++;
2377
    return $db_handle;
2378
}
2379

            
2380
sub create_database_schema {
2381
    my ($dbh) = @_;
2382
    $dbh->do(<<'SQL');
2383
CREATE TABLE IF NOT EXISTS schema_meta (
2384
    key TEXT PRIMARY KEY,
2385
    value TEXT NOT NULL,
2386
    updated_at TEXT NOT NULL
2387
)
2388
SQL
2389
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
2390
CREATE TABLE IF NOT EXISTS documents (
2391
    name TEXT PRIMARY KEY,
2392
    content TEXT NOT NULL,
2393
    updated_at TEXT NOT NULL
2394
)
2395
SQL
Bogdan Timofte authored 4 days ago
2396
    $dbh->do(
2397
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2398
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
Bogdan Timofte authored a day ago
2399
        undef, 'schema_version', '3', iso_now()
Bogdan Timofte authored 4 days ago
2400
    );
2401
    $dbh->do(<<'SQL');
2402
CREATE TABLE IF NOT EXISTS hosts (
2403
    fqdn TEXT PRIMARY KEY,
2404
    legacy_id TEXT NOT NULL UNIQUE,
2405
    status TEXT NOT NULL DEFAULT 'active',
2406
    hosts_ip TEXT NOT NULL DEFAULT '',
2407
    dns_ip TEXT NOT NULL DEFAULT '',
2408
    monitoring TEXT NOT NULL DEFAULT 'pending',
2409
    notes TEXT NOT NULL DEFAULT '',
2410
    created_at TEXT NOT NULL,
2411
    updated_at TEXT NOT NULL
2412
)
2413
SQL
2414
    $dbh->do(<<'SQL');
2415
CREATE TABLE IF NOT EXISTS host_aliases (
2416
    alias_name TEXT NOT NULL,
2417
    host_fqdn TEXT NOT NULL,
2418
    alias_kind TEXT NOT NULL DEFAULT 'declared',
2419
    status TEXT NOT NULL DEFAULT 'active',
2420
    is_dns_published INTEGER NOT NULL DEFAULT 1,
2421
    created_at TEXT NOT NULL,
2422
    retired_at TEXT,
2423
    notes TEXT NOT NULL DEFAULT '',
2424
    PRIMARY KEY (alias_name, host_fqdn),
2425
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2426
)
2427
SQL
2428
    $dbh->do(<<'SQL');
2429
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
2430
ON host_aliases(alias_name)
2431
WHERE status = 'active'
2432
SQL
2433
    $dbh->do(<<'SQL');
2434
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
2435
ON host_aliases(host_fqdn, status)
2436
SQL
2437
    $dbh->do(<<'SQL');
2438
CREATE TABLE IF NOT EXISTS host_roles (
2439
    host_fqdn TEXT NOT NULL,
2440
    role TEXT NOT NULL,
2441
    status TEXT NOT NULL DEFAULT 'active',
2442
    created_at TEXT NOT NULL,
2443
    retired_at TEXT,
2444
    PRIMARY KEY (host_fqdn, role),
2445
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2446
)
2447
SQL
2448
    $dbh->do(<<'SQL');
2449
CREATE TABLE IF NOT EXISTS host_sources (
2450
    host_fqdn TEXT NOT NULL,
2451
    source TEXT NOT NULL,
2452
    status TEXT NOT NULL DEFAULT 'active',
2453
    created_at TEXT NOT NULL,
2454
    retired_at TEXT,
2455
    PRIMARY KEY (host_fqdn, source),
2456
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2457
)
2458
SQL
2459
    $dbh->do(<<'SQL');
2460
CREATE TABLE IF NOT EXISTS host_flags (
2461
    host_fqdn TEXT NOT NULL,
2462
    flag TEXT NOT NULL,
2463
    value TEXT NOT NULL DEFAULT '1',
2464
    created_at TEXT NOT NULL,
2465
    updated_at TEXT NOT NULL,
2466
    PRIMARY KEY (host_fqdn, flag),
2467
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2468
)
Bogdan Timofte authored a day ago
2469
SQL
2470
    $dbh->do(<<'SQL');
2471
CREATE TABLE IF NOT EXISTS tags (
2472
    tag_id TEXT PRIMARY KEY,
2473
    label TEXT NOT NULL UNIQUE,
2474
    color TEXT NOT NULL DEFAULT '#647084',
2475
    icon TEXT NOT NULL DEFAULT 'tag',
2476
    notes TEXT NOT NULL DEFAULT '',
2477
    created_at TEXT NOT NULL,
2478
    updated_at TEXT NOT NULL
2479
)
2480
SQL
2481
    $dbh->do(<<'SQL');
2482
CREATE TABLE IF NOT EXISTS host_tags (
2483
    host_fqdn TEXT NOT NULL,
2484
    tag_id TEXT NOT NULL,
2485
    status TEXT NOT NULL DEFAULT 'active',
2486
    created_at TEXT NOT NULL,
2487
    retired_at TEXT,
2488
    PRIMARY KEY (host_fqdn, tag_id),
2489
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2490
    FOREIGN KEY (tag_id) REFERENCES tags(tag_id) ON UPDATE CASCADE ON DELETE RESTRICT
2491
)
2492
SQL
2493
    $dbh->do(<<'SQL');
2494
CREATE INDEX IF NOT EXISTS idx_host_tags_tag_status
2495
ON host_tags(tag_id, status)
Bogdan Timofte authored 4 days ago
2496
SQL
2497
    $dbh->do(<<'SQL');
2498
CREATE TABLE IF NOT EXISTS host_ssh (
2499
    host_fqdn TEXT NOT NULL,
2500
    profile_name TEXT NOT NULL DEFAULT 'default',
2501
    username TEXT NOT NULL DEFAULT '',
2502
    port INTEGER NOT NULL DEFAULT 22,
2503
    identity_file TEXT NOT NULL DEFAULT '',
2504
    address TEXT NOT NULL DEFAULT '',
2505
    local_forward_host TEXT NOT NULL DEFAULT '',
2506
    local_forward_port INTEGER,
2507
    remote_forward_host TEXT NOT NULL DEFAULT '',
2508
    remote_forward_port INTEGER,
2509
    notes TEXT NOT NULL DEFAULT '',
2510
    created_at TEXT NOT NULL,
2511
    updated_at TEXT NOT NULL,
2512
    PRIMARY KEY (host_fqdn, profile_name),
2513
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
2514
)
Bogdan Timofte authored 3 days ago
2515
SQL
2516
    $dbh->do(<<'SQL');
2517
CREATE TABLE IF NOT EXISTS host_tls (
2518
    host_fqdn TEXT PRIMARY KEY,
2519
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2520
    certificate_id TEXT,
2521
    notes TEXT NOT NULL DEFAULT '',
2522
    created_at TEXT NOT NULL,
2523
    updated_at TEXT NOT NULL,
2524
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE CASCADE,
2525
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2526
)
2527
SQL
2528
    $dbh->do(<<'SQL');
2529
CREATE INDEX IF NOT EXISTS idx_host_tls_certificate
2530
ON host_tls(certificate_id)
Bogdan Timofte authored 4 days ago
2531
SQL
2532
    $dbh->do(<<'SQL');
2533
CREATE TABLE IF NOT EXISTS certificates (
2534
    certificate_id TEXT PRIMARY KEY,
2535
    host_fqdn TEXT,
2536
    common_name TEXT NOT NULL DEFAULT '',
2537
    subject TEXT NOT NULL DEFAULT '',
2538
    issuer TEXT NOT NULL DEFAULT '',
2539
    serial TEXT UNIQUE,
2540
    status TEXT NOT NULL DEFAULT 'issued',
2541
    not_before TEXT NOT NULL DEFAULT '',
2542
    not_after TEXT NOT NULL DEFAULT '',
2543
    fingerprint_sha256 TEXT UNIQUE,
2544
    cert_path TEXT NOT NULL DEFAULT '',
2545
    csr_path TEXT NOT NULL DEFAULT '',
2546
    created_at TEXT NOT NULL,
2547
    updated_at TEXT NOT NULL,
2548
    notes TEXT NOT NULL DEFAULT '',
2549
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2550
)
2551
SQL
2552
    $dbh->do(<<'SQL');
2553
CREATE TABLE IF NOT EXISTS certificate_dns_names (
2554
    certificate_id TEXT NOT NULL,
2555
    dns_name TEXT NOT NULL,
2556
    PRIMARY KEY (certificate_id, dns_name),
2557
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
2558
)
2559
SQL
2560
    $dbh->do(<<'SQL');
2561
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
2562
ON certificate_dns_names(dns_name)
2563
SQL
2564
    $dbh->do(<<'SQL');
2565
CREATE TABLE IF NOT EXISTS vhosts (
2566
    vhost_fqdn TEXT PRIMARY KEY,
2567
    host_fqdn TEXT NOT NULL,
2568
    status TEXT NOT NULL DEFAULT 'active',
2569
    service_name TEXT NOT NULL DEFAULT '',
2570
    upstream_url TEXT NOT NULL DEFAULT '',
2571
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2572
    certificate_id TEXT,
2573
    notes TEXT NOT NULL DEFAULT '',
2574
    created_at TEXT NOT NULL,
2575
    updated_at TEXT NOT NULL,
2576
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2577
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2578
)
2579
SQL
2580
    $dbh->do(<<'SQL');
2581
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
2582
ON vhosts(host_fqdn, status)
2583
SQL
2584
    $dbh->do(<<'SQL');
2585
CREATE TABLE IF NOT EXISTS data_workers (
2586
    worker_id TEXT PRIMARY KEY,
2587
    worker_type TEXT NOT NULL,
2588
    name TEXT NOT NULL DEFAULT '',
2589
    status TEXT NOT NULL DEFAULT 'active',
2590
    source TEXT NOT NULL DEFAULT '',
2591
    last_run_at TEXT,
2592
    notes TEXT NOT NULL DEFAULT '',
2593
    created_at TEXT NOT NULL,
2594
    updated_at TEXT NOT NULL
2595
)
2596
SQL
2597
    $dbh->do(<<'SQL');
2598
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
2599
ON data_workers(worker_type, status)
2600
SQL
2601
    $dbh->do(<<'SQL');
2602
CREATE TABLE IF NOT EXISTS dhcp_leases (
2603
    lease_key TEXT PRIMARY KEY,
2604
    worker_id TEXT NOT NULL,
2605
    host_fqdn TEXT,
2606
    observed_name TEXT NOT NULL DEFAULT '',
2607
    ip_address TEXT NOT NULL,
2608
    mac_address TEXT NOT NULL DEFAULT '',
2609
    lease_state TEXT NOT NULL DEFAULT '',
2610
    first_seen TEXT NOT NULL,
2611
    last_seen TEXT NOT NULL,
2612
    raw TEXT NOT NULL DEFAULT '',
2613
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2614
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2615
)
2616
SQL
2617
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
2618
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
2619
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
2620
    $dbh->do(<<'SQL');
2621
CREATE TABLE IF NOT EXISTS mdns_observations (
2622
    observation_key TEXT PRIMARY KEY,
2623
    worker_id TEXT NOT NULL,
2624
    host_fqdn TEXT,
2625
    observed_name TEXT NOT NULL,
2626
    ip_address TEXT NOT NULL,
2627
    rr_type TEXT NOT NULL DEFAULT 'A',
2628
    ttl INTEGER NOT NULL DEFAULT 0,
2629
    first_seen TEXT NOT NULL,
2630
    last_seen TEXT NOT NULL,
2631
    seen_count INTEGER NOT NULL DEFAULT 1,
2632
    last_peer TEXT NOT NULL DEFAULT '',
2633
    raw TEXT NOT NULL DEFAULT '',
2634
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2635
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2636
)
2637
SQL
2638
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
2639
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
2640
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
2641
    $dbh->do(<<'SQL');
2642
CREATE TABLE IF NOT EXISTS work_orders (
2643
    id TEXT PRIMARY KEY,
2644
    status TEXT NOT NULL DEFAULT 'pending',
2645
    title TEXT NOT NULL DEFAULT '',
2646
    reason TEXT NOT NULL DEFAULT '',
2647
    created_at TEXT NOT NULL,
2648
    confirmed_at TEXT NOT NULL DEFAULT '',
2649
    result TEXT NOT NULL DEFAULT '',
2650
    updated_at TEXT NOT NULL
2651
)
2652
SQL
2653
    $dbh->do(<<'SQL');
2654
CREATE TABLE IF NOT EXISTS work_order_checklist (
2655
    work_order_id TEXT NOT NULL,
2656
    item_id TEXT NOT NULL,
2657
    text TEXT NOT NULL DEFAULT '',
2658
    status TEXT NOT NULL DEFAULT 'pending',
2659
    owner TEXT NOT NULL DEFAULT '',
2660
    notes TEXT NOT NULL DEFAULT '',
2661
    updated_at TEXT NOT NULL DEFAULT '',
2662
    PRIMARY KEY (work_order_id, item_id),
2663
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
2664
)
2665
SQL
2666
    $dbh->do(<<'SQL');
2667
CREATE TABLE IF NOT EXISTS work_order_actions (
2668
    work_order_id TEXT NOT NULL,
2669
    position INTEGER NOT NULL,
2670
    type TEXT NOT NULL,
2671
    host_fqdn TEXT,
2672
    host_legacy_id TEXT NOT NULL DEFAULT '',
2673
    name TEXT NOT NULL DEFAULT '',
2674
    payload TEXT NOT NULL DEFAULT '',
2675
    PRIMARY KEY (work_order_id, position),
2676
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
2677
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2678
)
2679
SQL
Bogdan Timofte authored 4 days ago
2680
}
2681

            
Bogdan Timofte authored 4 days ago
2682
sub seed_database {
2683
    my ($dbh) = @_;
2684
    seed_default_workers($dbh);
2685

            
2686
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2687
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2688
        normalize_registry_policy($registry);
2689
        with_transaction($dbh, sub {
2690
            import_registry_to_db($dbh, $registry, 0);
2691
        });
2692
    }
2693

            
2694
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2695
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2696
        with_transaction($dbh, sub {
2697
            import_work_orders_to_db($dbh, $orders);
2698
        });
2699
    }
2700

            
2701
    seed_mdns_observations_from_yaml($dbh);
2702
}
2703

            
2704
sub with_transaction {
2705
    my ($dbh, $code) = @_;
2706
    return $code->() unless $dbh->{AutoCommit};
2707
    $dbh->begin_work;
2708
    my $ok = eval {
2709
        $code->();
2710
        1;
2711
    };
2712
    if (!$ok) {
2713
        my $err = $@ || 'transaction failed';
2714
        eval { $dbh->rollback };
2715
        die $err;
2716
    }
2717
    $dbh->commit;
2718
}
2719

            
2720
sub db_scalar {
2721
    my ($dbh, $sql, @bind) = @_;
2722
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2723
    return $value || 0;
2724
}
2725

            
2726
sub legacy_document_text {
2727
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2728
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2729
    return $row->{content} if $row && defined $row->{content};
2730
    return read_file($seed_path) if -f $seed_path;
2731
    return $default_text;
2732
}
2733

            
2734
sub load_registry_from_db {
2735
    my $dbh = dbh();
2736
    my $registry = {
2737
        version => 1,
2738
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2739
        policy => {},
2740
        hosts => [],
2741
    };
Bogdan Timofte authored 4 days ago
2742

            
Bogdan Timofte authored 4 days ago
2743
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2744
    $sth->execute;
2745
    while (my $row = $sth->fetchrow_hashref) {
2746
        my $fqdn = $row->{fqdn};
2747
        push @{ $registry->{hosts} }, {
2748
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2749
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2750
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2751
            ip => canonical_ip($row),
2752
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2753
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2754
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
Bogdan Timofte authored a day ago
2755
            tags => [ active_tags_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2756
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2757
            monitoring => $row->{monitoring},
2758
            notes => $row->{notes},
2759
        };
2760
    }
2761

            
2762
    return $registry;
Bogdan Timofte authored 4 days ago
2763
}
2764

            
Bogdan Timofte authored 4 days ago
2765
sub save_registry_to_db {
2766
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2767
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2768
    with_transaction($dbh, sub {
2769
        import_registry_to_db($dbh, $registry, 1);
2770
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2771
    });
2772
}
2773

            
2774
sub import_registry_to_db {
2775
    my ($dbh, $registry, $retire_missing) = @_;
2776
    my %seen;
2777
    for my $host (@{ $registry->{hosts} || [] }) {
2778
        my $fqdn = upsert_host_to_db($dbh, $host);
2779
        $seen{$fqdn} = 1 if $fqdn;
2780
    }
2781

            
2782
    return unless $retire_missing;
2783
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2784
    $sth->execute('retired');
2785
    while (my ($fqdn) = $sth->fetchrow_array) {
2786
        next if $seen{$fqdn};
2787
        retire_host_in_db($dbh, $fqdn);
2788
    }
2789
}
2790

            
2791
sub upsert_host_to_db {
2792
    my ($dbh, $host) = @_;
2793
    my $now = iso_now();
2794
    my $fqdn = canonical_host_fqdn($host);
2795
    return '' unless $fqdn;
Bogdan Timofte authored 2 days ago
2796
    my $legacy_id = clean_id($host->{id} || $fqdn);
Bogdan Timofte authored 4 days ago
2797
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2798
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2799
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2800
    my $notes = clean_scalar($host->{notes} || '');
2801

            
Bogdan Timofte authored 4 days ago
2802
    $dbh->do(
Bogdan Timofte authored 4 days ago
2803
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2804
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2805
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2806
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2807
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2808
        undef,
Bogdan Timofte authored 4 days ago
2809
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2810
    );
2811

            
2812
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2813
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored a day ago
2814
    sync_host_tags($dbh, $fqdn, [ clean_tag_labels($host->{tags}) ]);
Bogdan Timofte authored 4 days ago
2815
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2816
    return $fqdn;
2817
}
2818

            
Bogdan Timofte authored 3 days ago
2819
sub upsert_host_tls_row {
2820
    my ($dbh, $host_fqdn, $certificate_id, $now) = @_;
2821
    $certificate_id = clean_certificate_id($certificate_id || '');
2822
    $dbh->do(
2823
        'INSERT INTO host_tls (host_fqdn, tls_mode, certificate_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) '
2824
        . 'ON CONFLICT(host_fqdn) DO UPDATE SET tls_mode = excluded.tls_mode, certificate_id = excluded.certificate_id, updated_at = excluded.updated_at',
2825
        undef,
2826
        $host_fqdn,
2827
        length($certificate_id) ? 'local-ca' : 'none',
2828
        length($certificate_id) ? $certificate_id : undef,
2829
        '',
2830
        $now,
2831
        $now,
2832
    );
2833
}
2834

            
Bogdan Timofte authored 4 days ago
2835
sub sync_host_values {
2836
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2837
    my $now = iso_now();
2838
    my %active = map { $_ => 1 } @$values;
2839
    for my $value (@$values) {
2840
        $dbh->do(
2841
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2842
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2843
            undef,
2844
            $fqdn, $value, $now,
2845
        );
2846
    }
2847

            
2848
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2849
    $sth->execute($fqdn);
2850
    while (my ($value) = $sth->fetchrow_array) {
2851
        next if $active{$value};
2852
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2853
    }
2854
}
2855

            
Bogdan Timofte authored a day ago
2856
sub sync_host_tags {
2857
    my ($dbh, $fqdn, $labels) = @_;
2858
    my $now = iso_now();
2859
    my %active;
2860
    for my $label (@$labels) {
2861
        $label = clean_tag_label($label);
2862
        next unless length $label;
2863
        my $tag_id = upsert_tag_to_db($dbh, $label, '', '', '', $now);
2864
        next unless length $tag_id;
2865
        $active{$tag_id} = 1;
2866
        $dbh->do(
2867
            "INSERT INTO host_tags (host_fqdn, tag_id, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2868
            . "ON CONFLICT(host_fqdn, tag_id) DO UPDATE SET status = 'active', retired_at = ''",
2869
            undef,
2870
            $fqdn, $tag_id, $now,
2871
        );
2872
    }
2873

            
2874
    my $sth = $dbh->prepare("SELECT tag_id FROM host_tags WHERE host_fqdn = ? AND status = 'active'");
2875
    $sth->execute($fqdn);
2876
    while (my ($tag_id) = $sth->fetchrow_array) {
2877
        next if $active{$tag_id};
2878
        $dbh->do("UPDATE host_tags SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND tag_id = ?", undef, $now, $fqdn, $tag_id);
2879
    }
2880
}
2881

            
2882
sub upsert_tag_to_db {
2883
    my ($dbh, $label, $color, $icon, $notes, $now) = @_;
2884
    $label = clean_tag_label($label);
2885
    return '' unless length $label;
2886
    my $tag_id = clean_tag_id($label);
2887
    $color = clean_tag_color($color || '');
2888
    $icon = clean_tag_icon($icon || '');
2889
    $notes = clean_scalar($notes || '');
2890
    $dbh->do(
2891
        'INSERT INTO tags (tag_id, label, color, icon, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) '
2892
        . 'ON CONFLICT(tag_id) DO UPDATE SET label = excluded.label, '
2893
        . 'color = CASE WHEN excluded.color <> ? THEN excluded.color ELSE tags.color END, '
2894
        . 'icon = CASE WHEN excluded.icon <> ? THEN excluded.icon ELSE tags.icon END, '
2895
        . 'notes = CASE WHEN excluded.notes <> ? THEN excluded.notes ELSE tags.notes END, '
2896
        . 'updated_at = excluded.updated_at',
2897
        undef,
2898
        $tag_id, $label, $color, $icon, $notes, $now, $now,
2899
        default_tag_color(), default_tag_icon(), '',
2900
    );
2901
    return $tag_id;
2902
}
2903

            
Bogdan Timofte authored 4 days ago
2904
sub sync_host_aliases_and_vhosts {
2905
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2906
    my $now = iso_now();
2907
    my (%aliases, %vhosts);
2908
    if (my $short = short_alias_for_fqdn($fqdn)) {
2909
        $aliases{$short} = 1;
2910
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2911
    }
Bogdan Timofte authored 4 days ago
2912
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2913
        $name = normalize_dns_name($name);
2914
        next unless length $name;
2915
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2916
        $aliases{$name} = 1;
2917
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2918
        if (my $short = short_alias_for_fqdn($name)) {
2919
            $aliases{$short} = 1;
2920
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2921
        }
2922
    }
2923
    for my $name (@$vhosts_in) {
2924
        $name = normalize_dns_name($name);
2925
        next unless length $name;
2926
        $vhosts{$name} = 1;
2927
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2928
        if (my $short = short_alias_for_fqdn($name)) {
2929
            $aliases{$short} = 1;
2930
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2931
        }
2932
    }
2933

            
2934
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2935
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2936
}
2937

            
2938
sub upsert_alias_to_db {
2939
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2940
    my ($existing_fqdn) = $dbh->selectrow_array(
2941
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2942
        undef,
2943
        $alias,
2944
    );
2945
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2946
        if ($kind eq 'derived-vhost') {
2947
            $dbh->do(
2948
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2949
                undef,
2950
                $now, $alias, $existing_fqdn,
2951
            );
2952
        } else {
2953
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2954
        }
2955
    }
Bogdan Timofte authored 4 days ago
2956
    $dbh->do(
2957
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2958
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2959
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2960
        undef,
2961
        $alias, $fqdn, $kind, $now,
2962
    );
2963
}
2964

            
2965
sub upsert_vhost_to_db {
2966
    my ($dbh, $fqdn, $vhost, $now) = @_;
2967
    my $service = vhost_service_name($vhost);
2968
    $dbh->do(
2969
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2970
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2971
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2972
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2973
        undef,
2974
        $vhost, $fqdn, $service, $now, $now,
2975
    );
2976
}
2977

            
2978
sub retire_missing_names {
2979
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2980
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2981
    $sth->execute($fqdn);
2982
    while (my ($name) = $sth->fetchrow_array) {
2983
        next if $active->{$name};
2984
        if ($table eq 'host_aliases') {
2985
            $dbh->do(
2986
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2987
                undef, $now, $fqdn, $name,
2988
            );
2989
        } else {
2990
            $dbh->do(
2991
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2992
                undef, $now, $fqdn, $name,
2993
            );
2994
        }
2995
    }
2996
}
2997

            
2998
sub retire_host_in_db {
2999
    my ($dbh, $fqdn) = @_;
3000
    my $now = iso_now();
3001
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
3002
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
3003
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
3004
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
3005
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
Bogdan Timofte authored a day ago
3006
    $dbh->do("UPDATE host_tags SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
Bogdan Timofte authored 4 days ago
3007
}
3008

            
Bogdan Timofte authored 4 days ago
3009
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
3010
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
3011
    my @names;
Bogdan Timofte authored 4 days ago
3012
    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");
3013
    $aliases->execute($fqdn);
3014
    while (my ($name) = $aliases->fetchrow_array) {
3015
        push @names, $name;
3016
    }
Bogdan Timofte authored 4 days ago
3017
    return unique_preserve(@names);
3018
}
3019

            
3020
sub active_vhosts_for_host {
3021
    my ($dbh, $fqdn) = @_;
3022
    my @names;
Bogdan Timofte authored 4 days ago
3023
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
3024
    $vhosts->execute($fqdn);
3025
    while (my ($name) = $vhosts->fetchrow_array) {
3026
        push @names, $name;
3027
    }
3028
    return unique_preserve(@names);
3029
}
3030

            
3031
sub active_values_for_host {
3032
    my ($dbh, $table, $column, $fqdn) = @_;
3033
    my @values;
3034
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
3035
    $sth->execute($fqdn);
3036
    while (my ($value) = $sth->fetchrow_array) {
3037
        push @values, $value;
3038
    }
3039
    return @values;
3040
}
3041

            
Bogdan Timofte authored a day ago
3042
sub active_tags_for_host {
3043
    my ($dbh, $fqdn) = @_;
3044
    my @values;
3045
    my $sth = $dbh->prepare(<<'SQL');
3046
SELECT t.label
3047
FROM host_tags ht
3048
JOIN tags t ON t.tag_id = ht.tag_id
3049
WHERE ht.host_fqdn = ? AND ht.status = 'active'
3050
ORDER BY t.label
3051
SQL
3052
    $sth->execute($fqdn);
3053
    while (my ($label) = $sth->fetchrow_array) {
3054
        push @values, clean_tag_label($label);
3055
    }
3056
    return @values;
3057
}
3058

            
3059
sub tags_payload {
3060
    my ($dbh) = @_;
3061
    my @tags;
3062
    my $sth = $dbh->prepare(<<'SQL');
3063
SELECT
3064
    t.tag_id,
3065
    t.label,
3066
    t.color,
3067
    t.icon,
3068
    t.notes,
3069
    COUNT(CASE WHEN ht.status = 'active' THEN 1 END) AS host_count
3070
FROM tags t
3071
LEFT JOIN host_tags ht ON ht.tag_id = t.tag_id
3072
GROUP BY t.tag_id, t.label, t.color, t.icon, t.notes
3073
ORDER BY t.label
3074
SQL
3075
    $sth->execute;
3076
    while (my $row = $sth->fetchrow_hashref) {
3077
        push @tags, tag_payload($row);
3078
    }
3079
    return {
3080
        tags => \@tags,
3081
        counts => {
3082
            tags => scalar @tags,
3083
            associations => sum(map { $_->{host_count} || 0 } @tags),
3084
        },
3085
    };
3086
}
3087

            
3088
sub tag_catalog_by_label {
3089
    my ($dbh) = @_;
3090
    my %tags;
3091
    for my $tag (@{ tags_payload($dbh)->{tags} || [] }) {
3092
        $tags{ clean_tag_label($tag->{label}) } = $tag;
3093
    }
3094
    return %tags;
3095
}
3096

            
3097
sub tag_payload_by_label {
3098
    my ($dbh, $label) = @_;
3099
    $label = clean_tag_label($label);
3100
    my $row = $dbh->selectrow_hashref(<<'SQL', undef, $label);
3101
SELECT
3102
    t.tag_id,
3103
    t.label,
3104
    t.color,
3105
    t.icon,
3106
    t.notes,
3107
    COUNT(CASE WHEN ht.status = 'active' THEN 1 END) AS host_count
3108
FROM tags t
3109
LEFT JOIN host_tags ht ON ht.tag_id = t.tag_id
3110
WHERE t.label = ?
3111
GROUP BY t.tag_id, t.label, t.color, t.icon, t.notes
3112
SQL
3113
    return $row ? tag_payload($row) : default_tag_payload($label);
3114
}
3115

            
3116
sub tag_payload {
3117
    my ($row) = @_;
3118
    return {
3119
        tag_id => clean_tag_id($row->{tag_id} || $row->{label} || ''),
3120
        label => clean_tag_label($row->{label} || $row->{tag_id} || ''),
3121
        color => clean_tag_color($row->{color} || ''),
3122
        icon => clean_tag_icon($row->{icon} || ''),
3123
        notes => clean_scalar($row->{notes} || ''),
3124
        host_count => int($row->{host_count} || 0),
3125
    };
3126
}
3127

            
3128
sub default_tag_payload {
3129
    my ($label) = @_;
3130
    $label = clean_tag_label($label);
3131
    return {
3132
        tag_id => clean_tag_id($label),
3133
        label => $label,
3134
        color => default_tag_color(),
3135
        icon => default_tag_icon(),
3136
        notes => '',
3137
        host_count => 0,
3138
    };
3139
}
3140

            
3141
sub observations_payload {
3142
    my ($dbh) = @_;
3143
    my @dhcp = observed_dhcp_leases($dbh);
3144
    my @mdns = observed_mdns_records($dbh);
3145
    return {
3146
        dhcp_leases => \@dhcp,
3147
        mdns_observations => \@mdns,
3148
        counts => {
3149
            dhcp_leases => scalar @dhcp,
3150
            mdns_observations => scalar @mdns,
3151
        },
3152
    };
3153
}
3154

            
3155
sub observed_dhcp_leases {
3156
    my ($dbh) = @_;
3157
    my @rows;
3158
    my $sth = $dbh->prepare(<<'SQL');
3159
SELECT lease_key, worker_id, host_fqdn, observed_name, ip_address, mac_address, lease_state, first_seen, last_seen
3160
FROM dhcp_leases
3161
ORDER BY last_seen DESC
3162
LIMIT 200
3163
SQL
3164
    $sth->execute;
3165
    while (my $row = $sth->fetchrow_hashref) {
3166
        push @rows, observation_payload_row($dbh, 'dhcp', $row);
3167
    }
3168
    return @rows;
3169
}
3170

            
3171
sub observed_mdns_records {
3172
    my ($dbh) = @_;
3173
    my @rows;
3174
    my $sth = $dbh->prepare(<<'SQL');
3175
SELECT observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer
3176
FROM mdns_observations
3177
ORDER BY last_seen DESC
3178
LIMIT 200
3179
SQL
3180
    $sth->execute;
3181
    while (my $row = $sth->fetchrow_hashref) {
3182
        push @rows, observation_payload_row($dbh, 'mdns', $row);
3183
    }
3184
    return @rows;
3185
}
3186

            
3187
sub observation_payload_row {
3188
    my ($dbh, $source, $row) = @_;
3189
    my $name = normalize_dns_name($row->{observed_name} || '');
3190
    my $ip = clean_ip($row->{ip_address} || '');
3191
    my $candidate_fqdn = candidate_fqdn_for_observed_name($name);
3192
    my $host_by_ip = host_fqdn_for_ip($dbh, $ip);
3193
    return {
3194
        source => $source,
3195
        key => clean_scalar($row->{lease_key} || $row->{observation_key} || ''),
3196
        worker_id => clean_scalar($row->{worker_id} || ''),
3197
        observed_name => $name,
3198
        candidate_fqdn => $candidate_fqdn,
3199
        ip_address => $ip,
3200
        mac_address => clean_mac($row->{mac_address} || ''),
3201
        state => clean_scalar($row->{lease_state} || $row->{rr_type} || ''),
3202
        host_fqdn => clean_scalar($row->{host_fqdn} || ''),
3203
        existing_host_fqdn => $host_by_ip,
3204
        first_seen => clean_scalar($row->{first_seen} || ''),
3205
        last_seen => clean_scalar($row->{last_seen} || ''),
3206
        seen_count => int($row->{seen_count} || 0),
3207
    };
3208
}
3209

            
3210
sub candidate_fqdn_for_observed_name {
3211
    my ($name) = @_;
3212
    $name = normalize_dns_name($name || '');
3213
    return '' unless length $name;
3214
    $name =~ s/\.local\z//;
3215
    return '' unless length $name;
3216
    return $name if $name =~ /\.madagascar\.xdev\.ro\z/;
3217
    return "$name.madagascar.xdev.ro" unless $name =~ /\./;
3218
    return '';
3219
}
3220

            
3221
sub host_fqdn_for_ip {
3222
    my ($dbh, $ip) = @_;
3223
    return '' unless clean_ip($ip || '');
3224
    my ($fqdn) = $dbh->selectrow_array(
3225
        'SELECT fqdn FROM hosts WHERE (dns_ip = ? OR hosts_ip = ?) AND status <> ? ORDER BY fqdn LIMIT 1',
3226
        undef,
3227
        $ip,
3228
        $ip,
3229
        'retired',
3230
    );
3231
    return $fqdn || '';
3232
}
3233

            
Bogdan Timofte authored 4 days ago
3234
sub load_work_orders_from_db {
3235
    my $dbh = dbh();
3236
    my $orders = { version => 1, work_orders => [] };
3237
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
3238
    $sth->execute;
3239
    while (my $row = $sth->fetchrow_hashref) {
3240
        my $wo = {
3241
            id => $row->{id},
3242
            status => $row->{status},
3243
            title => $row->{title},
3244
            reason => $row->{reason},
3245
            created_at => $row->{created_at},
3246
            checklist => [],
3247
            actions => [],
3248
        };
3249
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
3250
        $wo->{result} = $row->{result} if length($row->{result} || '');
3251

            
3252
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
3253
        $items->execute($row->{id});
3254
        while (my $item = $items->fetchrow_hashref) {
3255
            my %copy = (
3256
                id => $item->{item_id},
3257
                text => $item->{text},
3258
                status => $item->{status},
3259
            );
3260
            for my $key (qw(owner notes updated_at)) {
3261
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
3262
            }
3263
            push @{ $wo->{checklist} }, \%copy;
3264
        }
3265

            
3266
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
3267
        $actions->execute($row->{id});
3268
        while (my $action = $actions->fetchrow_hashref) {
3269
            my %copy = ( type => $action->{type} );
3270
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
3271
            $copy{name} = $action->{name} if length($action->{name} || '');
3272
            push @{ $wo->{actions} }, \%copy;
3273
        }
3274

            
3275
        push @{ $orders->{work_orders} }, $wo;
3276
    }
3277
    return $orders;
3278
}
3279

            
3280
sub save_work_orders_to_db {
3281
    my ($orders) = @_;
3282
    my $dbh = dbh();
3283
    with_transaction($dbh, sub {
3284
        import_work_orders_to_db($dbh, $orders);
3285
    });
3286
}
3287

            
3288
sub import_work_orders_to_db {
3289
    my ($dbh, $orders) = @_;
3290
    my $now = iso_now();
3291
    my %seen;
3292
    for my $wo (@{ $orders->{work_orders} || [] }) {
3293
        my $id = clean_scalar($wo->{id} || '');
3294
        next unless $id;
3295
        $seen{$id} = 1;
3296
        $dbh->do(
3297
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
3298
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
3299
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
3300
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
3301
            undef,
3302
            $id,
3303
            clean_scalar($wo->{status} || 'pending'),
3304
            clean_scalar($wo->{title} || ''),
3305
            clean_scalar($wo->{reason} || ''),
3306
            clean_scalar($wo->{created_at} || $now),
3307
            clean_scalar($wo->{confirmed_at} || ''),
3308
            clean_scalar($wo->{result} || ''),
3309
            $now,
3310
        );
3311
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
3312
        for my $item (@{ $wo->{checklist} || [] }) {
3313
            $dbh->do(
3314
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
3315
                undef,
3316
                $id,
3317
                clean_scalar($item->{id} || ''),
3318
                clean_scalar($item->{text} || ''),
3319
                clean_scalar($item->{status} || 'pending'),
3320
                clean_scalar($item->{owner} || ''),
3321
                clean_scalar($item->{notes} || ''),
3322
                clean_scalar($item->{updated_at} || ''),
3323
            );
3324
        }
3325
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
3326
        my $position = 0;
3327
        for my $action (@{ $wo->{actions} || [] }) {
3328
            my $legacy_id = clean_id($action->{host_id} || '');
3329
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
3330
            $dbh->do(
3331
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
3332
                undef,
3333
                $id,
3334
                $position++,
3335
                clean_scalar($action->{type} || ''),
3336
                $host_fqdn || undef,
3337
                $legacy_id,
3338
                normalize_dns_name($action->{name} || ''),
3339
                '',
3340
            );
3341
        }
3342
    }
3343
}
3344

            
3345
sub seed_default_workers {
3346
    my ($dbh) = @_;
3347
    my $now = iso_now();
3348
    my @workers = (
3349
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
3350
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
3351
    );
3352
    for my $worker (@workers) {
3353
        $dbh->do(
3354
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
3355
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
3356
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
3357
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
3358
            undef,
3359
            @$worker,
3360
            $now,
3361
            $now,
3362
        );
3363
    }
3364
}
3365

            
3366
sub seed_mdns_observations_from_yaml {
3367
    my ($dbh) = @_;
3368
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
3369
    my $path = "$project_dir/var/mdns-observations.yaml";
3370
    return unless -f $path;
3371
    my $db = parse_mdns_observations_yaml(read_file($path));
3372
    with_transaction($dbh, sub {
3373
        for my $observation (@{ $db->{observations} || [] }) {
3374
            $dbh->do(
3375
                '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) '
3376
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
3377
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
3378
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
3379
                undef,
3380
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
3381
                clean_scalar($observation->{name} || ''),
3382
                clean_scalar($observation->{ip} || ''),
3383
                int($observation->{ttl} || 0),
3384
                clean_scalar($observation->{first_seen} || iso_now()),
3385
                clean_scalar($observation->{last_seen} || iso_now()),
3386
                int($observation->{seen_count} || 1),
3387
                clean_scalar($observation->{last_peer} || ''),
3388
            );
3389
        }
3390
    });
3391
}
3392

            
3393
sub parse_mdns_observations_yaml {
3394
    my ($text) = @_;
3395
    my %db = ( observations => [] );
3396
    my ($section, $current);
3397
    for my $line (split /\n/, $text || '') {
3398
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
3399
        if ($line =~ /^observations:\s*$/) {
3400
            $section = 'observations';
3401
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
3402
            $current = { key => yaml_unquote($1) };
3403
            push @{ $db{observations} }, $current;
3404
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
3405
            $current->{$1} = yaml_unquote($2);
3406
        }
3407
    }
3408
    return \%db;
3409
}
3410

            
3411
sub set_schema_meta {
3412
    my ($dbh, $key, $value) = @_;
3413
    $dbh->do(
3414
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
3415
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
3416
        undef,
3417
        $key,
3418
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
3419
        iso_now(),
3420
    );
3421
}
3422

            
Bogdan Timofte authored 4 days ago
3423
sub fqdn_for_legacy_id {
3424
    my ($dbh, $legacy_id) = @_;
3425
    return '' unless length($legacy_id || '');
Bogdan Timofte authored 2 days ago
3426
    my ($fqdn) = $dbh->selectrow_array(
3427
        'SELECT fqdn FROM hosts WHERE legacy_id = ? OR fqdn = ?',
3428
        undef,
3429
        $legacy_id,
3430
        $legacy_id,
3431
    );
Bogdan Timofte authored 4 days ago
3432
    return $fqdn || '';
3433
}
3434

            
3435
sub canonical_host_fqdn {
3436
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
3437
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
3438
    return $fqdn if length $fqdn;
3439
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
3440
    for my $name (@names) {
3441
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
3442
    }
3443
    for my $name (@names) {
3444
        return $name if $name =~ /\./ && !name_is_vhost($name);
3445
    }
3446
    my $id = clean_id($host->{id} || '');
3447
    return $id ? "$id.madagascar.xdev.ro" : '';
3448
}
3449

            
3450
sub legacy_id_from_fqdn {
3451
    my ($fqdn) = @_;
3452
    $fqdn = normalize_dns_name($fqdn);
3453
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
3454
    $fqdn =~ s/\..*\z//;
3455
    return clean_id($fqdn);
3456
}
3457

            
3458
sub normalize_dns_name {
3459
    my ($name) = @_;
3460
    $name = lc clean_scalar($name || '');
3461
    $name =~ s/\.\z//;
3462
    return $name;
3463
}
3464

            
3465
sub name_is_vhost {
3466
    my ($name) = @_;
3467
    $name = normalize_dns_name($name);
3468
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
3469
}
3470

            
Bogdan Timofte authored 3 days ago
3471
sub vhost_name_is_valid {
3472
    my ($name) = @_;
3473
    $name = normalize_dns_name($name);
3474
    return 0 unless length $name;
3475
    return 0 unless $name eq 'madagascar.xdev.ro' || $name =~ /\.madagascar\.xdev\.ro\z/;
3476
    return 0 unless length($name) <= 253;
3477
    for my $label (split /\./, $name) {
3478
        return 0 unless length($label) >= 1 && length($label) <= 63;
3479
        return 0 unless $label =~ /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/;
3480
    }
3481
    return 1;
3482
}
3483

            
Bogdan Timofte authored 4 days ago
3484
sub vhost_service_name {
3485
    my ($name) = @_;
3486
    $name = normalize_dns_name($name);
3487
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
3488
    return '';
3489
}
3490

            
3491
sub short_alias_for_fqdn {
3492
    my ($name) = @_;
3493
    $name = normalize_dns_name($name);
3494
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
3495
    return '';
3496
}
3497

            
Bogdan Timofte authored 4 days ago
3498
sub normalize_registry_policy {
3499
    my ($registry) = @_;
3500
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
3501
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
3502
    $registry->{policy}{runtime_database} = $opt{db};
Bogdan Timofte authored a day ago
3503
    $registry->{policy}{finished_export} = 'hosts.yaml';
3504
    delete $registry->{policy}{dns_manifest};
Bogdan Timofte authored 4 days ago
3505
}
3506

            
3507
sub default_hosts_yaml {
3508
    return <<'YAML';
3509
version: 1
3510
updated_at: ""
3511
policy:
Bogdan Timofte authored 4 days ago
3512
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
3513
hosts:
3514
YAML
3515
}
3516

            
3517
sub default_work_orders_yaml {
3518
    return <<'YAML';
3519
version: 1
3520
work_orders:
3521
YAML
3522
}
3523

            
3524
sub ensure_parent_dir {
3525
    my ($path) = @_;
3526
    my $dir = dirname($path);
3527
    make_path($dir) unless -d $dir;
3528
}
3529

            
Xdev Host Manager authored a week ago
3530
sub url_decode {
3531
    my ($value) = @_;
3532
    $value = '' unless defined $value;
3533
    $value =~ tr/+/ /;
3534
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
3535
    return $value;
3536
}
3537

            
3538
sub random_hex {
3539
    my ($bytes) = @_;
3540
    if (open my $fh, '<:raw', '/dev/urandom') {
3541
        read($fh, my $raw, $bytes);
3542
        close $fh;
3543
        return unpack('H*', $raw);
3544
    }
3545
    return sha256_hex(rand() . time() . $$);
3546
}
3547

            
3548
sub iso_now {
3549
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
3550
}
3551

            
Bogdan Timofte authored 6 days ago
3552
sub build_info {
3553
    my %info = (
3554
        revision => '',
3555
        branch => '',
3556
        built_at => '',
3557
        deployed_at => '',
3558
        dirty => '',
3559
    );
3560

            
3561
    if ($ENV{HOST_MANAGER_BUILD}) {
3562
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
3563
        return \%info;
3564
    }
3565

            
3566
    my $build_file = "$project_dir/BUILD";
3567
    if (-f $build_file) {
3568
        for my $line (split /\n/, read_file($build_file)) {
3569
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
3570
            $info{$1} = clean_scalar($2);
3571
        }
3572
        return \%info if $info{revision} || $info{built_at};
3573
    }
3574

            
3575
    my $revision = git_value('rev-parse --short=12 HEAD');
3576
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
3577
    $info{revision} = $revision if $revision;
3578
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
3579
    return \%info;
3580
}
3581

            
3582
sub git_value {
3583
    my ($args) = @_;
3584
    return '' unless -d "$project_dir/.git";
3585
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
3586
    my $value = <$fh> || '';
3587
    close $fh;
3588
    chomp $value;
3589
    return clean_scalar($value);
3590
}
3591

            
3592
sub build_label {
3593
    my $info = build_info();
3594
    my $revision = $info->{revision} || 'unknown';
3595
    my $branch = $info->{branch} || '';
3596
    $branch = '' if $branch eq 'HEAD';
3597
    my $label = $branch ? "$branch $revision" : $revision;
3598
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
3599
    return $label;
3600
}
3601

            
3602
sub build_title {
3603
    my $info = build_info();
3604
    my $label = build_label();
3605
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
3606
    return $stamp ? "$label deployed $stamp" : $label;
3607
}
3608

            
Bogdan Timofte authored 4 days ago
3609
sub build_revision {
3610
    my $info = build_info();
3611
    return $info->{revision} || 'unknown';
3612
}
3613

            
3614
sub build_details {
3615
    my $info = build_info();
3616
    my %details = (
3617
        app => 'Madagascar Local Authority',
3618
        revision => $info->{revision} || 'unknown',
3619
        branch => $info->{branch} || '',
3620
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
3621
        built_at => $info->{built_at} || '',
3622
        deployed_at => $info->{deployed_at} || '',
3623
        label => build_label(),
3624
        title => build_title(),
3625
    );
3626
    return json_encode(\%details);
3627
}
3628

            
Bogdan Timofte authored 6 days ago
3629
sub html_escape {
3630
    my ($value) = @_;
3631
    $value = '' unless defined $value;
3632
    $value =~ s/&/&amp;/g;
3633
    $value =~ s/</&lt;/g;
3634
    $value =~ s/>/&gt;/g;
3635
    $value =~ s/"/&quot;/g;
3636
    $value =~ s/'/&#039;/g;
3637
    return $value;
3638
}
3639

            
Xdev Host Manager authored a week ago
3640
sub app_html {
Bogdan Timofte authored 4 days ago
3641
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
3642
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
3643
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
3644
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
3645
<!doctype html>
3646
<html lang="ro">
3647
<head>
3648
  <meta charset="utf-8">
3649
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
3650
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
3651
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
3652
  <style>
3653
    :root {
3654
      color-scheme: light;
3655
      --ink: #152033;
3656
      --muted: #647084;
3657
      --line: #d8dee8;
3658
      --soft: #f4f6f9;
3659
      --panel: #ffffff;
3660
      --accent: #1267d8;
3661
      --bad: #b42318;
3662
      --warn: #946200;
3663
      --ok: #137333;
3664
    }
3665
    * { box-sizing: border-box; }
3666
    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
3667

            
3668
    /* ── Login screen ── */
3669
    #login-screen {
3670
      display: flex;
Xdev Host Manager authored a week ago
3671
      align-items: flex-start;
Xdev Host Manager authored a week ago
3672
      justify-content: center;
3673
      min-height: 100dvh;
Xdev Host Manager authored a week ago
3674
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
3675
      background: #13182a;
Xdev Host Manager authored a week ago
3676
      overflow: auto;
Xdev Host Manager authored a week ago
3677
    }
3678
    .login-card {
Xdev Host Manager authored a week ago
3679
      --otp-size: 48px;
Xdev Host Manager authored a week ago
3680
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
3681
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
3682
      background: #fff;
3683
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
3684
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
3685
         below the first box, sits inside the card instead of spilling past it. */
3686
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
3687
      width: 100%;
Xdev Host Manager authored a week ago
3688
      max-width: 680px;
Bogdan Timofte authored 6 days ago
3689
      min-height: 360px;
Xdev Host Manager authored a week ago
3690
      display: grid;
Xdev Host Manager authored a week ago
3691
      align-content: start;
3692
      justify-items: center;
3693
      gap: 28px;
Xdev Host Manager authored a week ago
3694
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
3695
    }
Xdev Host Manager authored a week ago
3696
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
3697
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
3698
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
3699
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
3700
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
3701
    }
Xdev Host Manager authored a week ago
3702
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
3703
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
3704
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
3705
    .login-card form {
3706
      display: grid;
3707
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
3708
      justify-self: center;
Bogdan Timofte authored a week ago
3709
      padding-bottom: 0;
Xdev Host Manager authored a week ago
3710
    }
Xdev Host Manager authored a week ago
3711
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
3712
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
3713
       giving the password manager a username anchor and an aggregated OTP target
3714
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
3715
    .pm-helper-fields {
3716
      position: absolute;
3717
      left: -10000px;
3718
      top: auto;
3719
      width: 1px;
3720
      height: 1px;
3721
      overflow: hidden;
3722
      opacity: 0.01;
3723
    }
3724
    .pm-helper-fields input {
3725
      width: 1px;
3726
      height: 1px;
3727
      padding: 0;
3728
      border: 0;
3729
    }
Bogdan Timofte authored 4 days ago
3730
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
3731
       hint was what made Safari mark the whole group and re-present its OTP
3732
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
3733
    .otp-row {
3734
      display: flex;
3735
      gap: var(--otp-gap);
3736
      justify-content: center;
3737
    }
Bogdan Timofte authored 4 days ago
3738
    .otp-row input {
Xdev Host Manager authored a week ago
3739
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
3740
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
3741
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
3742
      transition: border-color .15s, background .15s;
3743
    }
Bogdan Timofte authored 4 days ago
3744
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
3745
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
3746
    #login-error {
3747
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
3748
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
3749
    }
3750
    @media (max-width: 760px) {
3751
      .login-card {
Xdev Host Manager authored a week ago
3752
        max-width: 520px;
Xdev Host Manager authored a week ago
3753
        min-height: 0;
Bogdan Timofte authored 4 days ago
3754
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
3755
        gap: 26px;
3756
      }
3757
      .login-card .brand h1 { font-size: 24px; }
3758
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
3759
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3760
    }
Xdev Host Manager authored a week ago
3761
    @media (max-width: 430px) {
3762
      #login-screen { padding: 24px 16px 120px; }
3763
      .login-card {
3764
        --otp-size: 42px;
Xdev Host Manager authored a week ago
3765
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
3766
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
3767
      }
Bogdan Timofte authored 4 days ago
3768
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
3769
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3770
    }
3771
    @media (max-height: 720px) {
3772
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
3773
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
3774
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
3775
    }
Xdev Host Manager authored a week ago
3776

            
3777
    /* ── App shell (hidden until authenticated) ── */
3778
    #app { display: none; }
Bogdan Timofte authored 5 days ago
3779
    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
3780
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
3781
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
3782
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
3783
    nav a:hover { color: var(--ink); background: var(--soft); }
3784
    nav a.active { color: var(--accent); background: #e8f0fe; }
3785
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
3786
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
3787
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
3788
    .page { display: grid; gap: 16px; }
3789
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
3790
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
3791
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
3792
    .panel { overflow: hidden; }
3793
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
3794
    .panel-head h2 { margin: 0; font-size: 14px; }
3795
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
3796
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
3797
    button, input, select, textarea { font: inherit; }
3798
    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; }
3799
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
3800
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
3801
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
3802
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
3803
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
3804
    textarea { min-height: 74px; resize: vertical; }
3805
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
3806
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
3807
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
3808
    tr:hover td { background: #f8fafc; }
3809
    .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; }
3810
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
3811
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
3812
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
3813
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
3814
    .pill.canonical { font-weight: 700; }
3815
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
3816
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
3817
    .span2 { grid-column: 1 / -1; }
3818
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
3819
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
3820
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
3821
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
3822
    .ca-fingerprint { overflow-wrap: anywhere; }
3823
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
3824
    .build-control {
Bogdan Timofte authored 6 days ago
3825
      position: fixed;
3826
      right: 10px;
3827
      bottom: 8px;
3828
      z-index: 5;
Bogdan Timofte authored 4 days ago
3829
      display: inline-flex;
3830
      align-items: center;
3831
      gap: 4px;
3832
    }
3833
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
3834
      color: rgba(255,255,255,.46);
3835
      background: rgba(19,24,42,.28);
3836
      border: 1px solid rgba(255,255,255,.08);
3837
      border-radius: 4px;
3838
      font-size: 10px;
3839
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
3840
    }
3841
    .build-badge {
3842
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
3843
      cursor: text;
3844
      user-select: text;
Bogdan Timofte authored 6 days ago
3845
    }
Bogdan Timofte authored 4 days ago
3846
    .build-copy {
3847
      min-height: 0;
3848
      padding: 2px 5px;
3849
      cursor: pointer;
3850
    }
3851
    .build-copy:hover {
3852
      color: rgba(255,255,255,.72);
3853
      border-color: rgba(255,255,255,.24);
3854
    }
3855
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
3856
      color: rgba(100,112,132,.58);
3857
      background: rgba(255,255,255,.72);
3858
      border-color: rgba(216,222,232,.72);
3859
    }
Bogdan Timofte authored 4 days ago
3860
    body.is-app .build-copy:hover {
3861
      color: rgba(21,32,51,.78);
3862
      border-color: rgba(100,112,132,.42);
3863
    }
Xdev Host Manager authored a week ago
3864
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
3865
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
3866
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
3867
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
3868
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
3869
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
3870
    .work-order-actions { gap: 4px; }
3871
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
3872
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
3873
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
3874
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
3875
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
3876
    .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
3877
    .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
3878
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
3879
    .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
3880
    .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; }
3881
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
3882
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
3883
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3884
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
3885
    .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; }
3886
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
3887
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
3888
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3889
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
3890
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
3891
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
3892
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 3 days ago
3893
    .host-alias-cell { display: grid; gap: 5px; min-width: 0; }
3894
    .host-alias-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3895
    .host-alias-pill { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin: 0; }
3896
    .host-alias-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3897
    .host-alias-remove, .host-alias-add { min-height: 28px; padding: 3px 7px; font-size: 12px; }
3898
    .host-alias-remove { min-height: 0; padding: 0; border: 0; background: transparent; color: var(--bad); }
3899
    .host-alias-remove:hover { background: transparent; }
Bogdan Timofte authored 3 days ago
3900
    .iconbtn { min-width: 34px; width: 34px; justify-content: center; padding: 7px; }
3901
    .iconbtn svg { width: 14px; height: 14px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
3902
    .host-actions { display: flex; align-items: center; gap: 6px; }
Bogdan Timofte authored 3 days ago
3903
    .field-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
Bogdan Timofte authored 3 days ago
3904
    .host-cert-cell { min-width: 0; }
Bogdan Timofte authored a day ago
3905
    .tag-list { display: flex; flex-wrap: wrap; gap: 4px; align-items: flex-start; }
3906
    .tag-pill { display: inline-flex; align-items: center; gap: 4px; margin: 0; color: var(--ink); border-color: var(--line); background: #fff; }
3907
    .tag-dot { width: 8px; height: 8px; border-radius: 999px; flex: 0 0 auto; background: var(--muted); }
3908
    .tag-editor { display: grid; grid-template-columns: minmax(220px, 1fr) 90px 150px auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
3909
    .tag-actions { display: flex; gap: 6px; flex-wrap: wrap; }
3910
    .tag-actions button { min-height: 30px; padding: 4px 8px; }
3911
    .raw-export { margin: 0; padding: 14px; min-height: 280px; max-height: 62vh; overflow: auto; background: #101827; color: #edf2ff; white-space: pre; }
3912
    .observation-hints { display: grid; gap: 6px; padding: 0 10px 10px; color: var(--muted); }
3913
    .host-inline-editor-shell .observation-hints { padding: 0; }
3914
    .observation-card { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: #f8fafc; }
3915
    .observation-card-main { display: grid; gap: 2px; min-width: 0; }
3916
    .observation-card-title { color: var(--ink); font-weight: 650; overflow-wrap: anywhere; }
3917
    .observation-card-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
3918
    .observation-card button { min-height: 30px; padding: 4px 8px; }
Bogdan Timofte authored 4 days ago
3919
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3920
    #page-vhosts .host-tools { flex-wrap: wrap; }
3921
    #page-vhosts .host-tools input { max-width: 280px; }
3922
    #page-vhosts .stats { justify-content: flex-end; }
Bogdan Timofte authored 3 days ago
3923
    #page-vhosts .table-wrap { overflow-x: visible; }
3924
    #page-vhosts table { min-width: 0; }
Bogdan Timofte authored 3 days ago
3925
    #page-vhosts th, #page-vhosts td { overflow-wrap: normal; }
3926
    #page-vhosts .pill.vhost { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; }
Bogdan Timofte authored 3 days ago
3927
    .vhost-name-cell { display: grid; gap: 5px; min-width: 0; }
3928
    .vhost-name-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-width: 0; }
3929
    .vhost-delete { min-height: 28px; padding: 3px 7px; color: var(--bad); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3930
    .vhost-host { display: grid; gap: 2px; }
3931
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
3932
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
3933
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 3 days ago
3934
    .vhost-cert { display: grid; gap: 5px; min-width: 0; }
3935
    .vhost-cert-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; }
3936
    .vhost-cert-select { width: 100%; max-width: 100%; min-height: 34px; }
3937
    .vhost-cert-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
3938
    .vhost-cert-links { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
3939
    .vhost-cert-links .linkbtn { padding: 3px 7px; font-size: 12px; }
3940
    .vhost-cert-validity { font-size: 12px; }
Bogdan Timofte authored 4 days ago
3941
    .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 3 days ago
3942
    .host-inline-row td { padding: 0; background: #fff; }
3943
    .host-inline-editor-shell { background: #fff; }
3944
    .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; }
3945
    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3946
    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
3947
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
3948
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
3949
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
3950
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
3951
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
3952
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
3953
      #message { max-width: 100%; }
3954
      .panel-head { align-items: stretch; flex-direction: column; }
3955
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
3956
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
3957
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 3 days ago
3958
      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3959
      .host-inline-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
3960
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored a day ago
3961
      .tag-editor { grid-template-columns: 1fr; }
Xdev Host Manager authored a week ago
3962
      .grid { grid-template-columns: 1fr; }
3963
      table { min-width: 760px; }
3964
      .table-wrap { overflow-x: auto; }
3965
    }
3966
  </style>
3967
</head>
Bogdan Timofte authored 6 days ago
3968
<body class="is-login">
Xdev Host Manager authored a week ago
3969

            
Xdev Host Manager authored a week ago
3970
  <!-- ── Login screen ── -->
3971
  <div id="login-screen">
3972
    <div class="login-card">
3973
      <div class="brand">
3974
        <div class="icon">
Xdev Host Manager authored a week ago
3975
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3976
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3977
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3978
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3979
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3980
            <path d="M26 20h8M26 32h8M26 44h8"/>
3981
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3982
          </svg>
3983
        </div>
Xdev Host Manager authored a week ago
3984
        <h1>Madagascar Local Authority</h1>
3985
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3986
      </div>
Bogdan Timofte authored 4 days ago
3987
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3988
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3989
        <div class="pm-helper-fields" aria-hidden="true">
3990
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3991
          <input type="hidden" id="otp-hidden" name="otp">
3992
        </div>
Xdev Host Manager authored a week ago
3993
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3994
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3995
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3996
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3997
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3998
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3999
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
4000
        </div>
4001
      </form>
4002
    </div>
4003
  </div>
4004

            
4005
  <!-- ── App (shown after login) ── -->
4006
  <div id="app">
4007
    <header>
Xdev Host Manager authored a week ago
4008
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
4009
      <nav aria-label="Sections">
4010
        <a href="/overview" data-page-link="overview">Overview</a>
4011
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
4012
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored a day ago
4013
        <a href="/tags" data-page-link="tags">Tags</a>
Bogdan Timofte authored 5 days ago
4014
        <a href="/dns" data-page-link="dns">DNS</a>
4015
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
4016
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
4017
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
4018
      </nav>
Xdev Host Manager authored a week ago
4019
      <div class="header-right">
4020
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
4021
        <span id="message" class="muted"></span>
4022
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
4023
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
4024
      </div>
Xdev Host Manager authored a week ago
4025
    </header>
4026
    <main>
Bogdan Timofte authored 5 days ago
4027
      <section class="page" id="page-overview" data-page="overview">
4028
        <section class="panel">
4029
          <div class="panel-head">
4030
            <h2>Overview</h2>
4031
            <div class="stats" id="stats"></div>
4032
          </div>
4033
          <div class="problems" id="problems"></div>
4034
        </section>
Xdev Host Manager authored a week ago
4035
      </section>
4036

            
Bogdan Timofte authored 5 days ago
4037
      <section class="page" id="page-hosts" data-page="hosts" hidden>
4038
        <section class="panel">
4039
          <div class="panel-head">
4040
            <h2>Hosts</h2>
4041
            <div class="host-tools">
4042
              <input id="filter" placeholder="filter">
4043
              <button type="button" id="new-host">New host</button>
4044
            </div>
4045
          </div>
4046
          <div class="table-wrap">
4047
            <table>
4048
              <thead>
4049
                <tr>
Bogdan Timofte authored 3 days ago
4050
                  <th style="width: 280px">Host</th>
Bogdan Timofte authored 4 days ago
4051
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 3 days ago
4052
                  <th>Aliases</th>
Bogdan Timofte authored 5 days ago
4053
                  <th style="width: 150px">Roles</th>
Bogdan Timofte authored a day ago
4054
                  <th style="width: 160px">Tags</th>
Bogdan Timofte authored 3 days ago
4055
                  <th style="width: 260px">Certificate</th>
Bogdan Timofte authored 5 days ago
4056
                  <th style="width: 110px">Monitoring</th>
4057
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 3 days ago
4058
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
4059
                </tr>
4060
              </thead>
4061
              <tbody id="hosts"></tbody>
4062
            </table>
4063
          </div>
4064
        </section>
Xdev Host Manager authored a week ago
4065
      </section>
Xdev Host Manager authored a week ago
4066

            
Bogdan Timofte authored 4 days ago
4067
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
4068
        <section class="panel">
4069
          <div class="panel-head">
4070
            <h2>Vhosts</h2>
4071
            <div class="host-tools">
4072
              <input id="vhost-filter" placeholder="filter">
4073
              <div class="stats" id="vhost-stats"></div>
4074
            </div>
4075
          </div>
Bogdan Timofte authored 4 days ago
4076
          <div class="vhost-inline-editor">
Bogdan Timofte authored a day ago
4077
            <input id="vhost-new-name" placeholder="vhost fqdn" list="vhost-name-hints">
Bogdan Timofte authored 4 days ago
4078
            <select id="vhost-new-host"></select>
4079
            <button type="button" id="vhost-add">Add</button>
4080
          </div>
Bogdan Timofte authored a day ago
4081
          <datalist id="vhost-name-hints"></datalist>
4082
          <div id="vhost-observation-hints" class="observation-hints"></div>
Bogdan Timofte authored 4 days ago
4083
          <div class="table-wrap">
4084
            <table>
4085
              <thead>
4086
                <tr>
Bogdan Timofte authored 3 days ago
4087
                  <th style="width: 22%">Vhost</th>
Bogdan Timofte authored 3 days ago
4088
                  <th style="width: 28%">Host</th>
4089
                  <th style="width: 34%">Certificate</th>
Bogdan Timofte authored 3 days ago
4090
                  <th style="width: 8%">Monitoring</th>
4091
                  <th style="width: 6%">Status</th>
Bogdan Timofte authored 4 days ago
4092
                </tr>
4093
              </thead>
4094
              <tbody id="vhosts"></tbody>
4095
            </table>
4096
          </div>
4097
        </section>
4098
      </section>
4099

            
Bogdan Timofte authored 5 days ago
4100
      <section class="page" id="page-dns" data-page="dns" hidden>
4101
        <section class="toolbar">
4102
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
4103
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
Bogdan Timofte authored a day ago
4104
          <button id="publish-dns">Publish resolvers</button>
4105
        </section>
4106
        <section class="panel">
4107
          <div class="panel-head">
4108
            <h2>hosts.yaml</h2>
4109
            <div class="stats" id="hosts-yaml-stats"></div>
4110
          </div>
4111
          <pre class="raw-export mono" id="hosts-yaml-raw"></pre>
4112
        </section>
4113
      </section>
4114

            
4115
      <section class="page" id="page-tags" data-page="tags" hidden>
4116
        <section class="panel">
4117
          <div class="panel-head">
4118
            <h2>Tags</h2>
4119
            <div class="stats" id="tag-stats"></div>
4120
          </div>
4121
          <div class="tag-editor">
4122
            <input id="tag-new-label" placeholder="tag">
4123
            <input id="tag-new-color" type="color" value="#647084" aria-label="Tag color">
4124
            <select id="tag-new-icon" aria-label="Tag icon">
4125
              <option value="tag">tag</option>
4126
              <option value="server">server</option>
4127
              <option value="storage">storage</option>
4128
              <option value="shield">shield</option>
4129
              <option value="network">network</option>
4130
              <option value="alert">alert</option>
4131
            </select>
4132
            <button type="button" id="tag-add">Save</button>
4133
          </div>
4134
          <div class="table-wrap">
4135
            <table>
4136
              <thead>
4137
                <tr>
4138
                  <th>Tag</th>
4139
                  <th style="width: 110px">Color</th>
4140
                  <th style="width: 150px">Icon</th>
4141
                  <th style="width: 120px">Hosts</th>
4142
                  <th style="width: 130px">Actions</th>
4143
                </tr>
4144
              </thead>
4145
              <tbody id="tags"></tbody>
4146
            </table>
4147
          </div>
Bogdan Timofte authored 5 days ago
4148
        </section>
Xdev Host Manager authored a week ago
4149
      </section>
4150

            
Bogdan Timofte authored 5 days ago
4151
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
4152
        <section class="panel">
4153
          <div class="panel-head">
4154
            <h2>Work Orders</h2>
4155
            <div class="stats" id="wo-stats"></div>
4156
          </div>
4157
          <div class="problems" id="work-orders"></div>
4158
        </section>
Xdev Host Manager authored a week ago
4159
      </section>
4160

            
Bogdan Timofte authored 5 days ago
4161
      <section class="page" id="page-ca" data-page="ca" hidden>
4162
        <section class="panel">
4163
          <div class="panel-head">
4164
            <h2>Local Certificate Authority</h2>
4165
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
4166
          </div>
4167
          <div class="problems" id="ca-status"></div>
4168
        </section>
4169
        <section class="panel">
4170
          <div class="panel-head">
4171
            <h2>Issued Certificates</h2>
4172
            <div class="stats" id="ca-certs-summary"></div>
4173
          </div>
4174
          <div class="table-wrap">
4175
            <table>
4176
              <thead>
4177
                <tr>
4178
                  <th style="width: 150px">Name</th>
4179
                  <th>DNS names</th>
4180
                  <th style="width: 210px">Validity</th>
4181
                  <th style="width: 180px">Serial</th>
4182
                  <th>Fingerprint</th>
4183
                  <th style="width: 110px">Download</th>
4184
                </tr>
4185
              </thead>
4186
              <tbody id="ca-certs"></tbody>
4187
            </table>
4188
          </div>
4189
        </section>
Xdev Host Manager authored a week ago
4190
      </section>
Bogdan Timofte authored 4 days ago
4191

            
4192
      <section class="page" id="page-debug" data-page="debug" hidden>
4193
        <section class="panel">
4194
          <div class="panel-head">
4195
            <h2>Database</h2>
4196
            <div class="stats" id="debug-db-stats"></div>
4197
          </div>
4198
          <div class="toolbar">
4199
            <div class="debug-controls">
4200
              <button type="button" id="debug-db-refresh">Refresh</button>
4201
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
4202
            </div>
4203
          </div>
Bogdan Timofte authored 4 days ago
4204
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
4205
        </section>
4206
        <section class="debug-section">
4207
          <section class="panel">
4208
            <div class="panel-head">
4209
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
4210
              <div class="debug-table-head-actions">
4211
                <div class="stats" id="debug-table-stats"></div>
4212
                <div class="debug-table-exports">
4213
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
4214
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
4215
                </div>
4216
              </div>
Bogdan Timofte authored 4 days ago
4217
            </div>
4218
            <div class="table-wrap" id="debug-table-rows"></div>
4219
          </section>
4220
          <section class="panel">
4221
            <div class="panel-head">
4222
              <h2>Columns</h2>
4223
            </div>
4224
            <div class="table-wrap" id="debug-table-columns"></div>
4225
          </section>
4226
          <section class="panel">
4227
            <div class="panel-head">
4228
              <h2>Indexes</h2>
4229
            </div>
4230
            <div class="table-wrap" id="debug-table-indexes"></div>
4231
          </section>
4232
          <section class="panel">
4233
            <div class="panel-head">
4234
              <h2>Foreign Keys</h2>
4235
            </div>
4236
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
4237
          </section>
4238
        </section>
4239
      </section>
Bogdan Timofte authored 5 days ago
4240
    </main>
Xdev Host Manager authored a week ago
4241

            
4242
  </div>
4243

            
Bogdan Timofte authored 4 days ago
4244
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
4245
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
4246
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
4247
  </div>
Bogdan Timofte authored 6 days ago
4248

            
Xdev Host Manager authored a week ago
4249
  <script>
Bogdan Timofte authored a day ago
4250
    let state = { hosts: [], vhosts: [], tags: [], observations: {}, certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
4251
    let hostFormSnapshot = '';
Bogdan Timofte authored 3 days ago
4252
    let hostFormBusy = false;
4253
    let hostFormMode = 'new';
Bogdan Timofte authored 3 days ago
4254
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
4255

            
4256
    const $ = (id) => document.getElementById(id);
4257
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 3 days ago
4258
    const hostFormShell = document.createElement('div');
4259
    hostFormShell.id = 'host-form-shell';
4260
    hostFormShell.className = 'host-inline-editor-shell';
4261
    hostFormShell.hidden = true;
4262
    hostFormShell.innerHTML = `
4263
      <div class="host-inline-editor-head">
4264
        <h2 id="host-form-title">New host</h2>
4265
        <div class="host-inline-editor-tools">
Bogdan Timofte authored 3 days ago
4266
          <button class="primary" type="submit" id="save-host" form="host-form">Save host</button>
4267
          <button class="danger" type="button" id="delete-host">Delete host</button>
Bogdan Timofte authored 3 days ago
4268
          <button type="button" id="cancel-host-form">Close</button>
4269
        </div>
4270
      </div>
4271
      <form id="host-form" class="grid">
Bogdan Timofte authored 2 days ago
4272
        <input type="hidden" name="id">
Bogdan Timofte authored a day ago
4273
        <label>FQDN<input name="fqdn" required list="host-fqdn-hints"></label>
Bogdan Timofte authored 3 days ago
4274
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
Bogdan Timofte authored a day ago
4275
        <label>IP<input name="ip" required list="host-ip-hints"></label>
Bogdan Timofte authored 3 days ago
4276
        <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 3 days ago
4277
        <label>Roles<input name="roles"></label>
Bogdan Timofte authored a day ago
4278
        <label>Tags<input name="tags" list="tag-hints"></label>
Bogdan Timofte authored 3 days ago
4279
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
4280
        <label>Notes<input name="notes"></label>
Bogdan Timofte authored a day ago
4281
        <div id="host-observation-hints" class="span2 observation-hints"></div>
4282
        <datalist id="host-fqdn-hints"></datalist>
4283
        <datalist id="host-ip-hints"></datalist>
4284
        <datalist id="tag-hints"></datalist>
Bogdan Timofte authored 3 days ago
4285
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
4286
      </form>`;
4287
    const hostForm = hostFormShell.querySelector('#host-form');
4288
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
4289
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
4290
    const saveHostButton = hostFormShell.querySelector('#save-host');
4291
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
4292
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
Bogdan Timofte authored 3 days ago
4293
    const hostAddAliasEditorButton = hostFormShell.querySelector('#host-add-alias-editor');
Bogdan Timofte authored 3 days ago
4294
    const hostEditorRow = document.createElement('tr');
4295
    hostEditorRow.className = 'host-inline-row';
4296
    const hostEditorCell = document.createElement('td');
Bogdan Timofte authored a day ago
4297
    hostEditorCell.colSpan = 9;
Bogdan Timofte authored 3 days ago
4298
    hostEditorRow.appendChild(hostEditorCell);
4299
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
4300
    const PAGE_PATHS = {
4301
      '/': 'overview',
4302
      '/overview': 'overview',
4303
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
4304
      '/vhosts': 'vhosts',
Bogdan Timofte authored a day ago
4305
      '/tags': 'tags',
Bogdan Timofte authored 5 days ago
4306
      '/dns': 'dns',
4307
      '/work-orders': 'work-orders',
4308
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
4309
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
4310
    };
Xdev Host Manager authored a week ago
4311

            
Bogdan Timofte authored 4 days ago
4312
    function isAuthLost(error) {
4313
      return !!(error && error.authLost);
4314
    }
4315

            
4316
    function authLostError(message) {
4317
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
4318
      error.authLost = true;
4319
      return error;
4320
    }
4321

            
4322
    function handleAuthLost(message) {
4323
      state.authenticated = false;
4324
      msg('');
4325
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
4326
    }
4327

            
Bogdan Timofte authored 4 days ago
4328
    async function ensureAuthenticated(message) {
4329
      if (!state.authenticated) {
4330
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
4331
        return false;
4332
      }
4333
      const session = await api('/api/session');
4334
      state.authenticated = session.authenticated;
4335
      if (!state.authenticated) {
4336
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
4337
        return false;
4338
      }
4339
      return true;
4340
    }
4341

            
Xdev Host Manager authored a week ago
4342
    async function api(path, options = {}) {
4343
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
4344
      let body = {};
4345
      try {
4346
        body = await res.json();
4347
      } catch (_) {
4348
        body = {};
4349
      }
4350
      const errorCode = body.error || '';
4351
      if (!res.ok) {
4352
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
4353
          const error = authLostError();
4354
          handleAuthLost(error.message);
4355
          throw error;
4356
        }
Bogdan Timofte authored 3 days ago
4357
        const error = new Error(body.detail || errorCode || res.statusText);
4358
        error.code = errorCode;
4359
        throw error;
Bogdan Timofte authored 4 days ago
4360
      }
Xdev Host Manager authored a week ago
4361
      return body;
4362
    }
4363

            
Bogdan Timofte authored a day ago
4364
    async function fetchText(path, options = {}) {
4365
      const res = await fetch(path, options);
4366
      const text = await res.text();
4367
      if (!res.ok) {
4368
        if (res.status === 401) {
4369
          const error = authLostError();
4370
          handleAuthLost(error.message);
4371
          throw error;
4372
        }
4373
        throw new Error(text || res.statusText);
4374
      }
4375
      return text;
4376
    }
4377

            
Bogdan Timofte authored 5 days ago
4378
    function currentPage() {
4379
      return PAGE_PATHS[window.location.pathname] || 'overview';
4380
    }
4381

            
4382
    function showPage(page, push = false) {
4383
      const target = page || 'overview';
4384
      document.querySelectorAll('[data-page]').forEach(section => {
4385
        section.hidden = section.dataset.page !== target;
4386
      });
4387
      document.querySelectorAll('[data-page-link]').forEach(link => {
4388
        link.classList.toggle('active', link.dataset.pageLink === target);
4389
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
4390
      });
4391
      if (push) {
4392
        const href = target === 'overview' ? '/overview' : '/' + target;
4393
        history.pushState({ page: target }, '', href);
4394
      }
Bogdan Timofte authored 4 days ago
4395
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
4396
        renderDebugDatabase().catch(e => {
4397
          if (!isAuthLost(e)) msg(e.message);
4398
        });
Bogdan Timofte authored 4 days ago
4399
      }
Bogdan Timofte authored a day ago
4400
      if (state.authenticated && target === 'dns') {
4401
        renderHostsYamlRaw().catch(e => {
4402
          if (!isAuthLost(e)) msg(e.message);
4403
        });
4404
      }
Bogdan Timofte authored 5 days ago
4405
    }
4406

            
Xdev Host Manager authored a week ago
4407
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
4408
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
4409
      document.body.classList.remove('is-app');
4410
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
4411
      $('app').style.display = 'none';
4412
      $('login-screen').style.display = 'flex';
4413
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
4414
      clearOtp();
Xdev Host Manager authored a week ago
4415
    }
4416

            
4417
    function showApp() {
Bogdan Timofte authored 6 days ago
4418
      document.body.classList.remove('is-login');
4419
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
4420
      $('login-screen').style.display = 'none';
4421
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
4422
      showPage(currentPage());
Xdev Host Manager authored a week ago
4423
    }
4424

            
Xdev Host Manager authored a week ago
4425
    async function refresh() {
4426
      const session = await api('/api/session');
4427
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
4428
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
4429
      showApp();
Xdev Host Manager authored a week ago
4430
      const data = await api('/api/hosts');
4431
      state.hosts = data.hosts || [];
Bogdan Timofte authored 3 days ago
4432
      state.vhosts = data.vhosts || [];
Bogdan Timofte authored a day ago
4433
      state.tags = data.tags || [];
4434
      state.observations = data.observations || {};
Bogdan Timofte authored 3 days ago
4435
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
4436
      state.problems = data.problems || [];
4437
      render(data);
Xdev Host Manager authored a week ago
4438
      await renderCa();
Xdev Host Manager authored a week ago
4439
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
4440
      if (currentPage() === 'debug') await renderDebugDatabase();
Bogdan Timofte authored a day ago
4441
      if (currentPage() === 'dns') await renderHostsYamlRaw();
Xdev Host Manager authored a week ago
4442
    }
4443

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

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

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

            
4457
      renderHosts();
Bogdan Timofte authored 4 days ago
4458
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
4459
      renderVhosts();
Bogdan Timofte authored a day ago
4460
      renderTags();
4461
      renderObservationDatalists();
4462
      renderVhostObservationHints();
4463
    }
4464

            
4465
    async function renderHostsYamlRaw() {
4466
      if (!state.authenticated || !$('hosts-yaml-raw')) return;
4467
      const text = await fetchText('/api/exports/hosts.yaml');
4468
      $('hosts-yaml-raw').textContent = text;
4469
      const lines = text ? text.split('\n').filter(Boolean).length : 0;
4470
      $('hosts-yaml-stats').innerHTML = [
4471
        ['lines', lines],
4472
        ['hosts', state.hosts.length],
4473
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4474
    }
4475

            
4476
    function renderTags() {
4477
      if (!$('tags')) return;
4478
      const tags = (state.tags || []).slice().sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
4479
      $('tag-stats').innerHTML = [
4480
        ['tags', tags.length],
4481
        ['associations', tags.reduce((total, tag) => total + Number(tag.host_count || 0), 0)],
4482
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4483
      $('tags').innerHTML = tags.length ? tags.map(tag => {
4484
        const id = tag.tag_id || tag.label || '';
4485
        return `<tr data-tag-id="${escapeHtml(id)}">
4486
          <td><input data-tag-label="${escapeHtml(id)}" value="${escapeHtml(tag.label || '')}"></td>
4487
          <td><input type="color" data-tag-color="${escapeHtml(id)}" value="${escapeHtml(tag.color || '#647084')}"></td>
4488
          <td>
4489
            <select data-tag-icon="${escapeHtml(id)}">
4490
              ${tagIconOptions(tag.icon || 'tag')}
4491
            </select>
4492
          </td>
4493
          <td><span class="pill">${escapeHtml(String(tag.host_count || 0))}</span></td>
4494
          <td><div class="tag-actions">
4495
            <button type="button" data-tag-save="${escapeHtml(id)}">Save</button>
4496
            <button type="button" class="danger" data-tag-delete="${escapeHtml(id)}">Delete</button>
4497
          </div></td>
4498
        </tr>`;
4499
      }).join('') : '<tr><td colspan="5" class="muted">No tags yet.</td></tr>';
4500
      document.querySelectorAll('[data-tag-save]').forEach(button => {
4501
        button.addEventListener('click', () => saveTagRow(button.dataset.tagSave || '').catch(e => {
4502
          if (!isAuthLost(e)) msg(e.message);
4503
        }));
4504
      });
4505
      document.querySelectorAll('[data-tag-delete]').forEach(button => {
4506
        button.addEventListener('click', () => deleteTagRow(button.dataset.tagDelete || '').catch(e => {
4507
          if (!isAuthLost(e)) msg(e.message);
4508
        }));
4509
      });
4510
    }
4511

            
4512
    function tagIconOptions(selected) {
4513
      return ['tag', 'server', 'storage', 'shield', 'network', 'alert']
4514
        .map(icon => `<option value="${escapeHtml(icon)}"${icon === selected ? ' selected' : ''}>${escapeHtml(icon)}</option>`)
4515
        .join('');
4516
    }
4517

            
4518
    function renderObservationDatalists() {
4519
      const hints = observationHints();
4520
      const fqdnOptions = unique(hints.map(hint => hint.candidate_fqdn).filter(Boolean));
4521
      const ipOptions = unique(hints.map(hint => hint.ip_address).filter(Boolean));
4522
      const vhostOptions = fqdnOptions;
4523
      if ($('host-fqdn-hints')) $('host-fqdn-hints').innerHTML = fqdnOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4524
      if ($('host-ip-hints')) $('host-ip-hints').innerHTML = ipOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4525
      if ($('vhost-name-hints')) $('vhost-name-hints').innerHTML = vhostOptions.map(value => `<option value="${escapeHtml(value)}"></option>`).join('');
4526
      if ($('tag-hints')) $('tag-hints').innerHTML = (state.tags || []).map(tag => `<option value="${escapeHtml(tag.label || '')}"></option>`).join('');
4527
    }
4528

            
4529
    async function addOrUpdateTag(label, color, icon) {
4530
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4531
      await api('/api/tags/upsert', {
4532
        method: 'POST',
4533
        headers: { 'Content-Type': 'application/json' },
4534
        body: JSON.stringify({ label, color, icon }),
4535
      });
4536
      msg(`tag ${label} saved`);
4537
      await refresh();
Xdev Host Manager authored a week ago
4538
    }
4539

            
Bogdan Timofte authored a day ago
4540
    async function saveTagRow(tagId) {
4541
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4542
      const selector = attrSelectorValue(tagId);
4543
      const label = document.querySelector(`[data-tag-label="${selector}"]`).value || '';
4544
      const color = document.querySelector(`[data-tag-color="${selector}"]`).value || '#647084';
4545
      const icon = document.querySelector(`[data-tag-icon="${selector}"]`).value || 'tag';
4546
      await api('/api/tags/rename', {
4547
        method: 'POST',
4548
        headers: { 'Content-Type': 'application/json' },
4549
        body: JSON.stringify({ tag_id: tagId, new_label: label, color, icon }),
4550
      });
4551
      msg(`tag ${label} saved`);
4552
      await refresh();
4553
    }
4554

            
4555
    async function deleteTagRow(tagId) {
4556
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4557
      if (!confirm(`Delete tag ${tagId} and remove it from hosts?`)) return;
4558
      await api('/api/tags/delete', {
4559
        method: 'POST',
4560
        headers: { 'Content-Type': 'application/json' },
4561
        body: JSON.stringify({ tag_id: tagId }),
4562
      });
4563
      msg(`tag ${tagId} deleted`);
4564
      await refresh();
4565
    }
4566

            
4567
    function observationHints() {
4568
      const dhcp = Array.isArray(state.observations.dhcp_leases) ? state.observations.dhcp_leases : [];
4569
      const mdns = Array.isArray(state.observations.mdns_observations) ? state.observations.mdns_observations : [];
4570
      return dhcp.concat(mdns).map(row => {
4571
        const observed = String(row.observed_name || '').toLowerCase();
4572
        const candidate = String(row.candidate_fqdn || candidateFqdnFromObserved(observed) || '').toLowerCase();
4573
        return {
4574
          source: row.source || '',
4575
          observed_name: observed,
4576
          candidate_fqdn: candidate,
4577
          ip_address: row.ip_address || '',
4578
          host_fqdn: row.host_fqdn || row.existing_host_fqdn || '',
4579
          last_seen: row.last_seen || '',
4580
        };
4581
      }).filter(row => row.observed_name || row.ip_address || row.candidate_fqdn);
4582
    }
4583

            
4584
    function candidateFqdnFromObserved(name) {
4585
      name = String(name || '').toLowerCase().replace(/\.$/, '');
4586
      if (!name) return '';
4587
      if (name.endsWith('.local')) name = name.slice(0, -6);
4588
      if (!name) return '';
4589
      if (name.endsWith('.madagascar.xdev.ro')) return name;
4590
      if (!name.includes('.')) return `${name}.madagascar.xdev.ro`;
4591
      return '';
4592
    }
4593

            
4594
    function matchingObservationHints(name, ip) {
4595
      const fqdn = candidateFqdnFromObserved(name) || normalizeHostFqdn(name);
4596
      const shortName = shortAliasForFqdn(fqdn) || String(name || '').toLowerCase().replace(/\.local$/, '');
4597
      return observationHints().filter(hint => {
4598
        const hintShort = shortAliasForFqdn(hint.candidate_fqdn) || String(hint.observed_name || '').replace(/\.local$/, '');
4599
        return (fqdn && hint.candidate_fqdn === fqdn)
4600
          || (shortName && hintShort === shortName)
4601
          || (ip && hint.ip_address === ip);
4602
      });
4603
    }
4604

            
4605
    function renderHostObservationHints() {
4606
      const box = hostFormShell.querySelector('#host-observation-hints');
4607
      if (!box) return;
4608
      const fqdn = hostField('fqdn').value || '';
4609
      const ip = hostField('ip').value || '';
4610
      const matches = matchingObservationHints(fqdn, ip).slice(0, 3);
4611
      box.innerHTML = matches.map(renderHostObservationCard).join('');
4612
      box.querySelectorAll('[data-use-observation-ip]').forEach(button => {
4613
        button.addEventListener('click', () => {
4614
          hostField('ip').value = button.dataset.useObservationIp || '';
4615
          hostField('ip').dispatchEvent(new Event('input', { bubbles: true }));
4616
        });
4617
      });
4618
      box.querySelectorAll('[data-open-observation-host]').forEach(button => {
4619
        button.addEventListener('click', () => {
4620
          editHostByFqdn(button.dataset.openObservationHost || '').catch(e => {
4621
            if (!isAuthLost(e)) msg(e.message);
4622
          });
4623
        });
4624
      });
4625
    }
4626

            
4627
    function renderHostObservationCard(hint) {
4628
      const host = hostByIp(hint.ip_address) || hostByFqdn(hint.host_fqdn);
4629
      const actions = [
4630
        hint.ip_address ? `<button type="button" data-use-observation-ip="${escapeHtml(hint.ip_address)}">Use IP</button>` : '',
4631
        host ? `<button type="button" data-open-observation-host="${escapeHtml(host.fqdn || '')}">Open host</button>` : '',
4632
      ].filter(Boolean).join('');
4633
      return `<div class="observation-card">
4634
        <div class="observation-card-main">
4635
          <div class="observation-card-title">${escapeHtml(hint.observed_name || hint.candidate_fqdn || '')} ${hint.ip_address ? `-> ${escapeHtml(hint.ip_address)}` : ''}</div>
4636
          <div>${escapeHtml(hint.source || 'observed')} ${hint.last_seen ? `seen ${escapeHtml(hint.last_seen)}` : ''}${host ? `, matches ${escapeHtml(host.fqdn || '')}` : ''}</div>
4637
        </div>
4638
        <div class="observation-card-actions">${actions}</div>
4639
      </div>`;
4640
    }
4641

            
4642
    function renderVhostObservationHints() {
4643
      const box = $('vhost-observation-hints');
4644
      if (!box) return;
4645
      const raw = $('vhost-new-name').value || '';
4646
      const vhost = normalizeVhostInput(raw);
4647
      const matches = matchingObservationHints(vhost, '').slice(0, 3);
4648
      box.innerHTML = matches.map(renderVhostObservationCard).join('');
4649
      box.querySelectorAll('[data-attach-observation-host]').forEach(button => {
4650
        button.addEventListener('click', () => {
4651
          $('vhost-new-host').value = button.dataset.attachObservationHost || '';
4652
          msg(`vhost target set to ${button.dataset.attachObservationHost || ''}`);
4653
        });
4654
      });
4655
      box.querySelectorAll('[data-create-host-from-observation]').forEach(button => {
4656
        button.addEventListener('click', () => {
4657
          newHostFromObservation(button.dataset.createHostFromObservation || '', button.dataset.observationIp || '').catch(e => {
4658
            if (!isAuthLost(e)) msg(e.message);
4659
          });
4660
        });
4661
      });
4662
    }
4663

            
4664
    function renderVhostObservationCard(hint) {
4665
      const host = hostByIp(hint.ip_address) || hostByFqdn(hint.host_fqdn);
4666
      const candidate = hint.candidate_fqdn || candidateFqdnFromObserved(hint.observed_name);
4667
      const actions = host
4668
        ? `<button type="button" data-attach-observation-host="${escapeHtml(host.fqdn || '')}">Attach to host</button>`
4669
        : `<button type="button" data-create-host-from-observation="${escapeHtml(candidate)}" data-observation-ip="${escapeHtml(hint.ip_address || '')}">New host</button>`;
4670
      return `<div class="observation-card">
4671
        <div class="observation-card-main">
4672
          <div class="observation-card-title">${escapeHtml(hint.observed_name || candidate)} ${hint.ip_address ? `-> ${escapeHtml(hint.ip_address)}` : ''}</div>
4673
          <div>${host ? `existing host ${escapeHtml(host.fqdn || '')}` : 'observed only'}</div>
4674
        </div>
4675
        <div class="observation-card-actions">${actions}</div>
4676
      </div>`;
4677
    }
4678

            
4679

            
Xdev Host Manager authored a week ago
4680
    async function renderCa() {
4681
      try {
4682
        const status = await api('/api/ca/status');
4683
        if (!status.initialized) {
4684
          $('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
4685
          $('ca-certs-summary').innerHTML = '';
4686
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
4687
          return;
4688
        }
4689
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 3 days ago
4690
        state.certificates = certs.map(cert => ({
4691
          ...cert,
4692
          id: cert.id || cert.name || '',
4693
          name: cert.name || cert.id || '',
4694
          has_private_key: !!cert.has_private_key
4695
        }));
Bogdan Timofte authored 5 days ago
4696
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
4697
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
4698
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
4699
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
4700
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
4701
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
4702
            <div>
4703
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
4704
              <span>${certs.length} issued certificate(s)</span>
4705
            </div>
Xdev Host Manager authored a week ago
4706
          </div>`;
Bogdan Timofte authored 5 days ago
4707
        $('ca-certs-summary').innerHTML = [
4708
          ['issued', certs.length],
4709
          ['expiring', certs.filter(cert => {
4710
            const days = daysUntil(cert.not_after);
4711
            return days !== null && days >= 0 && days <= 30;
4712
          }).length],
4713
          ['expired', certs.filter(cert => {
4714
            const days = daysUntil(cert.not_after);
4715
            return days !== null && days < 0;
4716
          }).length],
4717
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
4718
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
4719
          const days = daysUntil(cert.not_after);
4720
          const dnsNames = cert.dns_names || [];
4721
          const dnsHtml = dnsNames.length
4722
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
4723
            : '<span class="muted">No DNS SANs reported.</span>';
4724
          return `<tr>
4725
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
4726
            <td>${dnsHtml}</td>
4727
            <td>
4728
              <div class="ca-detail">
4729
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
4730
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
4731
              </div>
4732
            </td>
4733
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
4734
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 3 days ago
4735
            <td>
4736
              <div class="vhost-cert-links">
4737
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
4738
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
4739
              </div>
4740
            </td>
Bogdan Timofte authored 5 days ago
4741
          </tr>`;
4742
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
4743
      } catch (e) {
Bogdan Timofte authored 4 days ago
4744
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
4745
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
4746
        $('ca-certs-summary').innerHTML = '';
4747
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
4748
      }
4749
    }
4750

            
Bogdan Timofte authored 5 days ago
4751
    function daysUntil(dateText) {
4752
      const time = Date.parse(dateText || '');
4753
      if (!Number.isFinite(time)) return null;
4754
      return Math.ceil((time - Date.now()) / 86400000);
4755
    }
4756

            
4757
    function certStatusClass(days) {
4758
      if (days === null) return '';
4759
      if (days < 0) return 'bad';
4760
      if (days <= 30) return 'warn';
4761
      return 'ok';
4762
    }
4763

            
4764
    function certStatusLabel(days) {
4765
      if (days === null) return 'validity unknown';
4766
      if (days < 0) return 'expired';
4767
      if (days === 0) return 'expires today';
4768
      return `${days}d remaining`;
4769
    }
4770

            
Xdev Host Manager authored a week ago
4771
    async function renderWorkOrders() {
4772
      try {
4773
        const data = await api('/api/work-orders');
4774
        state.workOrders = data.work_orders || [];
4775
        $('wo-stats').innerHTML = [
4776
          ['pending', data.counts.pending],
4777
          ['total', data.counts.work_orders],
4778
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
4779

            
4780
        if (!state.workOrders.length) {
4781
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
4782
          return;
4783
        }
4784

            
4785
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
4786
          const checklist = wo.checklist || [];
4787
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
4788
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
4789
          const checklistHtml = checklist.map(item => {
4790
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
4791
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
4792
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
4793
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
4794
            </label>`;
4795
          }).join('');
Xdev Host Manager authored a week ago
4796
          const actions = (wo.actions || []).map(a => {
4797
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
4798
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
4799
          }).join('');
4800
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
4801
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
4802
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
4803
            : '';
Bogdan Timofte authored 6 days ago
4804
          return `<div class="problem work-order-card">
4805
            <div class="work-order-head">
Xdev Host Manager authored a week ago
4806
              <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
4807
              ${button}
4808
            </div>
Bogdan Timofte authored 6 days ago
4809
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
4810
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
4811
            <div class="work-order-checklist">${checklistHtml}</div>
4812
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
4813
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
4814
          </div>`;
4815
        }).join('');
Xdev Host Manager authored a week ago
4816
        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
4817
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
4818
      } catch (e) {
Bogdan Timofte authored 4 days ago
4819
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
4820
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
4821
      }
4822
    }
4823

            
Bogdan Timofte authored 4 days ago
4824
    async function renderDebugDatabase() {
4825
      if (!state.authenticated) return;
4826
      const data = await api('/api/debug/database/tables');
4827
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
4828
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
4829
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
4830
      $('debug-db-stats').innerHTML = [
4831
        ['tables', data.counts ? data.counts.tables : tables.length],
4832
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
4833
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
4834
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
4835
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
4836
      if (selected) {
4837
        await renderDebugTable(selected);
4838
      } else {
4839
        clearDebugTable();
4840
      }
4841
    }
4842

            
Bogdan Timofte authored 4 days ago
4843
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
4844
      $('debug-db-tables').innerHTML = tables.length
4845
        ? tables.map(table => {
4846
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
4847
            const ref = debugTableReference(database, table.name);
4848
            return `<div class="debug-table-card ${active ? 'active' : ''}">
4849
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
4850
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
4851
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
4852
              </button>
Bogdan Timofte authored 4 days ago
4853
              <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
4854
            </div>`;
Bogdan Timofte authored 4 days ago
4855
          }).join('')
4856
        : '<div class="ca-empty muted">No database tables found.</div>';
4857
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4858
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
4859
          if (!isAuthLost(e)) msg(e.message);
4860
        }));
4861
      });
Bogdan Timofte authored 4 days ago
4862
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
4863
        button.addEventListener('click', async () => {
4864
          try {
4865
            await copyText(button.dataset.debugTableRef || '');
4866
            msg('table reference copied');
4867
          } catch (e) {
4868
            msg('copy failed');
4869
          }
4870
        });
4871
      });
4872
    }
4873

            
4874
    function debugTableReference(database, tableName) {
4875
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
4876
    }
4877

            
4878
    async function selectDebugTable(tableName) {
4879
      state.debugTable = tableName || '';
4880
      document.querySelectorAll('[data-debug-table]').forEach(button => {
4881
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
4882
        const card = button.closest('.debug-table-card');
4883
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
4884
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
4885
      });
4886
      if (state.debugTable) await renderDebugTable(state.debugTable);
4887
    }
4888

            
4889
    function clearDebugTable() {
4890
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
4891
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
4892
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4893
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4894
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
4895
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
4896
    }
4897

            
4898
    async function renderDebugTable(tableName) {
4899
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
4900
      if (data.error) throw new Error(data.error);
4901
      $('debug-table-stats').innerHTML = [
4902
        ['table', data.table || tableName],
4903
        ['rows', data.row_count || 0],
4904
        ['shown', (data.rows || []).length],
4905
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
4906
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
4907
      renderDebugRows(data);
4908
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
4909
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
4910
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
4911
    }
4912

            
Bogdan Timofte authored 4 days ago
4913
    function updateDebugExportLinks(tableName) {
4914
      const encoded = encodeURIComponent(tableName || '');
4915
      [
4916
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
4917
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
4918
      ].forEach(([id, href]) => {
4919
        const link = $(id);
4920
        const enabled = !!tableName;
4921
        link.href = enabled ? href : '#';
4922
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
4923
      });
4924
    }
4925

            
Bogdan Timofte authored 4 days ago
4926
    function renderDebugRows(data) {
4927
      const rows = data.rows || [];
4928
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
4929
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
4930
    }
4931

            
4932
    function renderDebugObjectTable(rows, preferredKeys) {
4933
      const keys = preferredKeys && preferredKeys.length
4934
        ? preferredKeys
4935
        : Array.from(rows.reduce((set, row) => {
4936
            Object.keys(row || {}).forEach(key => set.add(key));
4937
            return set;
4938
          }, new Set()));
4939
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
4940
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
4941
      const body = rows.length
4942
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
4943
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
4944
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
4945
    }
4946

            
4947
    function debugCell(value) {
4948
      if (value === null || value === undefined) return 'NULL';
4949
      if (Array.isArray(value)) return value.join(', ');
4950
      if (typeof value === 'object') return JSON.stringify(value);
4951
      return String(value);
4952
    }
4953

            
Xdev Host Manager authored a week ago
4954
    async function updateWorkOrderChecklist(id, itemId, checked) {
4955
      try {
4956
        await api('/api/work-orders/checklist', {
4957
          method: 'POST',
4958
          headers: { 'Content-Type': 'application/json' },
4959
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
4960
        });
4961
        msg('work order updated');
4962
        await refresh();
Bogdan Timofte authored 4 days ago
4963
      } catch (e) {
4964
        if (isAuthLost(e)) return;
4965
        msg(e.message);
4966
        await refresh().catch(refreshError => {
4967
          if (!isAuthLost(refreshError)) msg(refreshError.message);
4968
        });
4969
      }
Xdev Host Manager authored a week ago
4970
    }
4971

            
Xdev Host Manager authored a week ago
4972
    async function confirmWorkOrder(id) {
4973
      const typed = prompt(`Type ${id} to confirm this work order`);
4974
      if (typed !== id) return;
4975
      try {
4976
        await api('/api/work-orders/confirm', {
4977
          method: 'POST',
4978
          headers: { 'Content-Type': 'application/json' },
4979
          body: JSON.stringify({ id, confirm: typed })
4980
        });
Bogdan Timofte authored a day ago
4981
        msg('work order confirmed; resolver sync queued');
Xdev Host Manager authored a week ago
4982
        await refresh();
Bogdan Timofte authored 4 days ago
4983
      } catch (e) {
4984
        if (isAuthLost(e)) return;
4985
        msg(e.message);
4986
      }
Xdev Host Manager authored a week ago
4987
    }
4988

            
Xdev Host Manager authored a week ago
4989
    function renderHosts() {
4990
      const filter = $('filter').value.toLowerCase();
4991
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
4992
        .slice()
Bogdan Timofte authored 3 days ago
4993
        .sort((a, b) => String(a.fqdn || a.id || '').localeCompare(String(b.fqdn || b.id || '')))
Xdev Host Manager authored a week ago
4994
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
4995
        .map(h => {
4996
          const problems = state.problems.filter(p => p.host_id === h.id);
4997
          const cls = problems.length ? 'warn' : 'ok';
Bogdan Timofte authored 3 days ago
4998
          return `<tr data-id="${escapeHtml(h.id)}" data-host-fqdn="${escapeHtml(h.fqdn || '')}">
Bogdan Timofte authored 3 days ago
4999
            <td><span class="pill canonical" title="${escapeHtml(h.fqdn || '')}">${escapeHtml(h.fqdn || '')}</span></td>
Bogdan Timofte authored 4 days ago
5000
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 3 days ago
5001
            <td>${renderHostAliasCell(h)}</td>
Xdev Host Manager authored a week ago
5002
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
Bogdan Timofte authored a day ago
5003
            <td>${renderHostTags(h)}</td>
Bogdan Timofte authored 3 days ago
5004
            <td class="host-cert-cell">${renderHostCertificateCell(h)}</td>
Xdev Host Manager authored a week ago
5005
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
5006
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 3 days ago
5007
            <td><div class="host-actions">
5008
              <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 || '')}">
5009
                <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>
5010
              </button>
5011
              <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 || '')}">
5012
                <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>
5013
              </button>
5014
            </div></td>
Xdev Host Manager authored a week ago
5015
          </tr>`;
5016
        }).join('');
Bogdan Timofte authored 4 days ago
5017
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
5018
        editHost(button.dataset.edit).catch(e => {
5019
          if (!isAuthLost(e)) msg(e.message);
5020
        });
5021
      }));
Bogdan Timofte authored 3 days ago
5022
      document.querySelectorAll('[data-host-delete]').forEach(button => button.addEventListener('click', () => {
5023
        deleteHostInline(button.dataset.hostDelete || '').catch(e => {
Bogdan Timofte authored 3 days ago
5024
          if (!isAuthLost(e)) msg(e.message);
5025
        });
5026
      }));
5027
      document.querySelectorAll('[data-host-alias-remove]').forEach(button => button.addEventListener('click', () => {
5028
        removeHostAlias(button.dataset.hostAliasRemove || '', button.dataset.hostAliasName || '').catch(e => {
5029
          if (!isAuthLost(e)) msg(e.message);
5030
        });
5031
      }));
5032
      document.querySelectorAll('[data-host-cert-select]').forEach(select => {
5033
        select.addEventListener('change', () => {
5034
          setHostCertificateFromSelect(select).catch(e => {
5035
            if (!isAuthLost(e)) msg(e.message);
5036
            select.value = select.dataset.currentCertificate || '';
5037
          });
5038
        });
5039
      });
5040
      document.querySelectorAll('[data-host-cert-issue]').forEach(button => {
5041
        button.addEventListener('click', () => {
5042
          issueHostCertificate(button.dataset.hostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
5043
            if (!isAuthLost(e)) msg(e.message);
5044
          });
5045
        });
5046
      });
Bogdan Timofte authored 3 days ago
5047
      mountHostEditor();
Xdev Host Manager authored a week ago
5048
    }
5049

            
Bogdan Timofte authored 3 days ago
5050
    function renderHostAliasCell(host) {
5051
      const aliases = (host.aliases || []).map(name => `<span class="pill host-alias-pill">
5052
        <span class="host-alias-label">${escapeHtml(name)}</span>
5053
        <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>
5054
      </span>`).join('');
5055
      return `<div class="host-alias-cell">
Bogdan Timofte authored 3 days ago
5056
        <div class="host-alias-list">${aliases}</div>
Bogdan Timofte authored 3 days ago
5057
      </div>`;
5058
    }
5059

            
Bogdan Timofte authored a day ago
5060
    function renderHostTags(host) {
5061
      const details = Array.isArray(host.tag_details) ? host.tag_details : [];
5062
      const tags = details.length ? details : (host.tags || []).map(label => tagByLabel(label) || { label, color: '#647084', icon: 'tag' });
5063
      return `<div class="tag-list">${tags.map(renderTagPill).join('')}</div>`;
5064
    }
5065

            
5066
    function renderTagPill(tag) {
5067
      const color = validColor(tag.color) ? tag.color : '#647084';
5068
      const icon = tag.icon || 'tag';
5069
      const label = tag.label || '';
5070
      return `<span class="pill tag-pill" title="${escapeHtml(icon)}">
5071
        <span class="tag-dot" style="background:${escapeHtml(color)}"></span>${escapeHtml(label)}
5072
      </span>`;
5073
    }
5074

            
Bogdan Timofte authored 3 days ago
5075
    function renderHostCertificateCell(host) {
5076
      const cert = host.certificate || {};
Bogdan Timofte authored 3 days ago
5077
      const certId = host.certificate_id || certificateIdOf(cert) || '';
Bogdan Timofte authored 3 days ago
5078
      const row = hostCertificateRow(host);
5079
      const links = certId ? `<div class="vhost-cert-links">
5080
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
5081
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
5082
      </div>` : '';
5083
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
5084
      return `<div class="vhost-cert">
5085
        <div class="vhost-cert-main">
5086
          <select class="vhost-cert-select" data-host-cert-select="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">
5087
            ${renderCertificateOptions(certId, row)}
5088
          </select>
5089
          <button type="button" data-host-cert-issue="${escapeHtml(host.fqdn || '')}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
5090
        </div>
5091
        <div class="vhost-cert-meta">${links}${validity}</div>
5092
      </div>`;
5093
    }
5094

            
5095
    function hostCertificateRow(host) {
5096
      return {
5097
        host_fqdn: host.fqdn || '',
5098
        aliases: Array.isArray(host.aliases) ? host.aliases : [],
5099
        derived_aliases: Array.isArray(host.derived_aliases) ? host.derived_aliases : [],
5100
        certificate_id: host.certificate_id || '',
5101
        certificate: host.certificate || null,
5102
      };
Bogdan Timofte authored 4 days ago
5103
    }
5104

            
5105
    function vhostRows() {
Bogdan Timofte authored 3 days ago
5106
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
5107
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
5108
        vhost,
5109
        host_id: host.id || '',
5110
        host_fqdn: host.fqdn || '',
5111
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
5112
        monitoring: host.monitoring || '',
5113
        status: host.status || '',
Bogdan Timofte authored 3 days ago
5114
        certificate_id: '',
5115
        certificate: null,
Bogdan Timofte authored 4 days ago
5116
      })));
5117
    }
5118

            
5119
    function renderVhosts() {
5120
      const input = $('vhost-filter');
5121
      const filter = input ? input.value.toLowerCase() : '';
5122
      const rows = vhostRows()
5123
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
5124
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
5125
      $('vhost-stats').innerHTML = [
5126
        ['shown', rows.length],
5127
        ['total', vhostRows().length],
5128
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
5129
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
Bogdan Timofte authored 3 days ago
5130
        <td>${renderVhostNameCell(row)}</td>
Bogdan Timofte authored 4 days ago
5131
        <td>
5132
          <div class="vhost-host">
5133
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
5134
              ${renderVhostHostOptions(row.host_fqdn)}
5135
            </select>
5136
          </div>
5137
        </td>
Bogdan Timofte authored 3 days ago
5138
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
5139
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
5140
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 3 days ago
5141
      </tr>`).join('') : '<tr><td colspan="5" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
5142
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
5143
        select.addEventListener('change', () => {
5144
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
5145
            if (!isAuthLost(e)) msg(e.message);
5146
            select.value = select.dataset.currentHost || '';
5147
          });
Bogdan Timofte authored 4 days ago
5148
        });
Bogdan Timofte authored 4 days ago
5149
      });
Bogdan Timofte authored 4 days ago
5150
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
5151
        button.addEventListener('click', () => {
5152
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
5153
            if (!isAuthLost(e)) msg(e.message);
5154
          });
5155
        });
5156
      });
Bogdan Timofte authored 3 days ago
5157
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
5158
        select.addEventListener('change', () => {
5159
          setVhostCertificateFromSelect(select).catch(e => {
5160
            if (!isAuthLost(e)) msg(e.message);
5161
            select.value = select.dataset.currentCertificate || '';
5162
          });
5163
        });
5164
      });
5165
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
5166
        button.addEventListener('click', () => {
Bogdan Timofte authored 3 days ago
5167
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 3 days ago
5168
            if (!isAuthLost(e)) msg(e.message);
5169
          });
5170
        });
5171
      });
5172
    }
5173

            
Bogdan Timofte authored 3 days ago
5174
    function renderVhostNameCell(row) {
5175
      return `<div class="vhost-name-cell">
5176
        <div class="vhost-name-main">
5177
          <span class="pill vhost" title="${escapeHtml(row.vhost)}">${escapeHtml(row.vhost)}</span>
5178
          <button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}" title="Delete ${escapeHtml(row.vhost)}">Del</button>
5179
        </div>
5180
      </div>`;
5181
    }
5182

            
Bogdan Timofte authored 3 days ago
5183
    function renderVhostCertificateCell(row) {
5184
      const cert = row.certificate || {};
5185
      const certId = row.certificate_id || cert.id || cert.name || '';
5186
      const links = certId ? `<div class="vhost-cert-links">
5187
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
5188
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
5189
      </div>` : '';
5190
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
5191
      return `<div class="vhost-cert">
5192
        <div class="vhost-cert-main">
5193
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
Bogdan Timofte authored 3 days ago
5194
            ${renderCertificateOptions(certId, row)}
Bogdan Timofte authored 3 days ago
5195
          </select>
5196
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
5197
        </div>
5198
        <div class="vhost-cert-meta">${links}${validity}</div>
5199
      </div>`;
Bogdan Timofte authored 4 days ago
5200
    }
5201

            
5202
    function renderVhostEditor() {
5203
      const select = $('vhost-new-host');
5204
      const current = select.value || '';
5205
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
5206
    }
5207

            
5208
    function renderVhostHostOptions(selectedHostFqdn) {
5209
      return state.hosts
5210
        .slice()
5211
        .filter(host => (host.status || '') !== 'retired')
5212
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
5213
        .map(host => {
5214
          const fqdn = host.fqdn || '';
5215
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
5216
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
5217
        }).join('');
Bogdan Timofte authored 4 days ago
5218
    }
5219

            
Bogdan Timofte authored 3 days ago
5220
    function renderCertificateOptions(selectedCertificateId, row) {
5221
      const byId = new Map();
5222
      (state.certificates || []).forEach(cert => {
Bogdan Timofte authored 3 days ago
5223
        const id = certificateIdOf(cert);
Bogdan Timofte authored 3 days ago
5224
        if (id) byId.set(id, cert);
5225
      });
5226
      if (row && row.certificate) {
Bogdan Timofte authored 3 days ago
5227
        const id = certificateIdOf(row.certificate);
Bogdan Timofte authored 3 days ago
5228
        if (id && !byId.has(id)) byId.set(id, row.certificate);
5229
      }
5230
      const certs = Array.from(byId.values())
Bogdan Timofte authored 3 days ago
5231
        .filter(cert => certMatchesRow(cert, row) || certificateIdOf(cert) === selectedCertificateId)
Bogdan Timofte authored 3 days ago
5232
        .sort((a, b) => {
5233
          const ar = certRelevance(a, row);
5234
          const br = certRelevance(b, row);
5235
          if (ar !== br) return ar - br;
5236
          return String(a.name || a.id || '').localeCompare(String(b.name || b.id || ''));
5237
        });
Bogdan Timofte authored 3 days ago
5238
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
Bogdan Timofte authored 3 days ago
5239
        const id = certificateIdOf(cert);
Bogdan Timofte authored 3 days ago
5240
        const label = compactCertificateLabel(cert, row);
Bogdan Timofte authored 3 days ago
5241
        const selected = id === selectedCertificateId ? ' selected' : '';
5242
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
5243
      }));
5244
      return options.join('');
5245
    }
5246

            
Bogdan Timofte authored 3 days ago
5247
    function certificateIdOf(cert) {
Bogdan Timofte authored 3 days ago
5248
      return cert ? (cert.id || cert.name || '') : '';
5249
    }
5250

            
5251
    function certDnsNames(cert) {
5252
      return (cert && Array.isArray(cert.dns_names) ? cert.dns_names : [])
5253
        .map(name => String(name || '').toLowerCase())
5254
        .filter(Boolean);
5255
    }
5256

            
5257
    function certRelevance(cert, row) {
5258
      if (!row) return 9;
5259
      const names = new Set(certDnsNames(cert));
Bogdan Timofte authored 3 days ago
5260
      const id = String(certificateIdOf(cert)).toLowerCase();
Bogdan Timofte authored 3 days ago
5261
      const commonName = String(cert.common_name || '').toLowerCase();
5262
      const vhost = String(row.vhost || '').toLowerCase();
Bogdan Timofte authored 3 days ago
5263
      const host = String(row.host_fqdn || row.fqdn || '').toLowerCase();
Bogdan Timofte authored 3 days ago
5264
      const vhostShort = shortAliasForFqdn(vhost);
Bogdan Timofte authored 3 days ago
5265
      const aliasNames = []
5266
        .concat(Array.isArray(row.aliases) ? row.aliases : [])
5267
        .concat(Array.isArray(row.derived_aliases) ? row.derived_aliases : [])
5268
        .map(name => String(name || '').toLowerCase())
5269
        .filter(Boolean);
5270
      if (vhost) {
5271
        if (names.has(vhost) || commonName === vhost || id.startsWith(vhost + '-')) return 0;
5272
        if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 1;
5273
        if ((vhostShort && names.has(vhostShort)) || aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 2;
5274
        return 9;
5275
      }
5276
      if (host && (names.has(host) || commonName === host || String(cert.host_fqdn || '').toLowerCase() === host || id.startsWith(host + '-'))) return 0;
5277
      if (aliasNames.some(name => names.has(name) || commonName === name || id.startsWith(name + '-'))) return 1;
Bogdan Timofte authored 3 days ago
5278
      return 9;
5279
    }
5280

            
5281
    function certMatchesRow(cert, row) {
5282
      return certRelevance(cert, row) < 9;
5283
    }
5284

            
5285
    function compactCertificateLabel(cert, row) {
5286
      const relevance = certRelevance(cert, row);
5287
      const days = daysUntil(cert.not_after);
5288
      const suffix = days === null ? '' : ` (${certStatusLabel(days)})`;
Bogdan Timofte authored 3 days ago
5289
      const name = certificateDisplayName(cert);
Bogdan Timofte authored 3 days ago
5290
      if (row && row.vhost) {
Bogdan Timofte authored 3 days ago
5291
        if (relevance === 0) return `${name}${suffix}`;
5292
        if (relevance === 1) return `host ${name}${suffix}`;
5293
        if (relevance === 2) return `alias ${name}${suffix}`;
Bogdan Timofte authored 3 days ago
5294
      } else {
Bogdan Timofte authored 3 days ago
5295
        if (relevance === 0) return `${name}${suffix}`;
5296
        if (relevance === 1) return `alias ${name}${suffix}`;
Bogdan Timofte authored 3 days ago
5297
      }
Bogdan Timofte authored 3 days ago
5298
      return `${shortCertificateName(cert)}${suffix}`;
5299
    }
5300

            
Bogdan Timofte authored 3 days ago
5301
    function certificateDisplayName(cert) {
5302
      const commonName = String(cert.common_name || '').trim();
5303
      if (commonName) return commonName;
5304
      const dnsNames = certDnsNames(cert);
5305
      if (dnsNames.length) return dnsNames[0];
5306
      return shortCertificateName(cert);
5307
    }
5308

            
Bogdan Timofte authored 3 days ago
5309
    function shortCertificateName(cert) {
5310
      const name = String(cert.common_name || cert.name || cert.id || '');
5311
      const suffix = '.madagascar.xdev.ro';
5312
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : name;
5313
    }
5314

            
Bogdan Timofte authored 4 days ago
5315
    function shortAliasForFqdn(name) {
5316
      const suffix = '.madagascar.xdev.ro';
5317
      name = String(name || '').toLowerCase();
5318
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
5319
    }
5320

            
Bogdan Timofte authored 2 days ago
5321
    function normalizeHostFqdn(name) {
5322
      return String(name || '').trim().toLowerCase().replace(/\.$/, '');
5323
    }
5324

            
5325
    function syncHostFormId() {
5326
      if (!hostField('id').value) {
5327
        hostField('id').value = normalizeHostFqdn(hostField('fqdn').value || '');
5328
      }
5329
    }
5330

            
Bogdan Timofte authored 3 days ago
5331
    function hostByFqdn(fqdn) {
5332
      fqdn = String(fqdn || '').toLowerCase();
5333
      return state.hosts.find(host => String(host.fqdn || '').toLowerCase() === fqdn) || null;
5334
    }
5335

            
Bogdan Timofte authored a day ago
5336
    function hostByIp(ip) {
5337
      ip = String(ip || '').trim();
5338
      return state.hosts.find(host => String(host.ip || '').trim() === ip) || null;
5339
    }
5340

            
5341
    function tagByLabel(label) {
5342
      label = normalizeTagLabel(label);
5343
      return (state.tags || []).find(tag => normalizeTagLabel(tag.label || '') === label) || null;
5344
    }
5345

            
5346
    function normalizeTagLabel(label) {
5347
      return String(label || '').trim().toLowerCase().replace(/[^a-z0-9_. -]+/g, '-').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
5348
    }
5349

            
5350
    function validColor(value) {
5351
      return /^#[0-9a-f]{6}$/i.test(String(value || ''));
5352
    }
5353

            
5354
    function unique(values) {
5355
      return Array.from(new Set(values.filter(Boolean)));
5356
    }
5357

            
Bogdan Timofte authored 3 days ago
5358
    function hostUpsertPayload(host, overrides = {}) {
5359
      const aliases = overrides.aliases !== undefined ? overrides.aliases : (host.aliases || []);
5360
      const payload = {
5361
        id: host.id || '',
5362
        fqdn: host.fqdn || '',
5363
        status: overrides.status !== undefined ? overrides.status : (host.status || 'active'),
5364
        ip: overrides.ip !== undefined ? overrides.ip : (host.ip || ''),
5365
        aliases,
5366
        roles: Array.isArray(overrides.roles) ? overrides.roles : (host.roles || []),
Bogdan Timofte authored a day ago
5367
        tags: Array.isArray(overrides.tags) ? overrides.tags : (host.tags || []),
Bogdan Timofte authored 3 days ago
5368
        sources: [],
Bogdan Timofte authored 3 days ago
5369
        monitoring: overrides.monitoring !== undefined ? overrides.monitoring : (host.monitoring || 'pending'),
5370
        notes: overrides.notes !== undefined ? overrides.notes : (host.notes || ''),
5371
      };
5372
      if (overrides.vhosts !== undefined) payload.vhosts = overrides.vhosts;
5373
      return payload;
5374
    }
5375

            
Bogdan Timofte authored 3 days ago
5376
    function aliasEditorValues() {
5377
      return (hostField('aliases').value || '')
5378
        .split(/[\s,]+/)
5379
        .map(value => String(value || '').trim().toLowerCase())
5380
        .filter(Boolean);
5381
    }
5382

            
5383
    function appendAliasInEditor() {
5384
      const fqdn = String(hostField('fqdn').value || '').trim().toLowerCase();
5385
      const derived = shortAliasForFqdn(fqdn);
5386
      const alias = String(prompt(fqdn ? `Alias nou pentru ${fqdn}` : 'Alias nou', '') || '').trim().toLowerCase();
5387
      if (!alias) return;
5388
      if (fqdn && alias === fqdn) {
5389
        msg('fqdn-ul hostului este deja numele principal');
5390
        return;
5391
      }
5392
      if (derived && alias === derived) {
5393
        msg('aliasul derivat din fqdn se genereaza automat');
5394
        return;
5395
      }
5396
      const aliases = aliasEditorValues();
5397
      if (aliases.includes(alias)) {
5398
        msg(`aliasul ${alias} este deja in editor`);
5399
        return;
5400
      }
5401
      hostField('aliases').value = aliases.concat(alias).join('\n');
5402
      hostField('aliases').dispatchEvent(new Event('input', { bubbles: true }));
5403
      hostField('aliases').focus();
5404
    }
5405

            
Bogdan Timofte authored 3 days ago
5406
    async function addHostAlias(hostFqdn) {
5407
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
5408
      const host = hostByFqdn(hostFqdn);
5409
      if (!host) return;
5410
      const alias = String(prompt(`Alias nou pentru ${host.fqdn}`, '') || '').trim().toLowerCase();
5411
      if (!alias) return;
5412
      if (alias === String(host.fqdn || '').toLowerCase()) {
5413
        msg('fqdn-ul hostului este deja prezent');
5414
        return;
5415
      }
5416
      const aliases = Array.from(new Set([...(host.aliases || []), alias]));
5417
      await api('/api/hosts/upsert', {
5418
        method: 'POST',
5419
        headers: { 'Content-Type': 'application/json' },
5420
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
5421
      });
5422
      msg(`alias ${alias} adaugat pe ${host.fqdn}`);
5423
      await refresh();
5424
    }
5425

            
5426
    async function removeHostAlias(hostFqdn, alias) {
5427
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
5428
      const host = hostByFqdn(hostFqdn);
5429
      alias = String(alias || '').trim().toLowerCase();
5430
      if (!host || !alias) return;
5431
      if (!confirm(`Sterg aliasul ${alias} de pe ${host.fqdn}?`)) return;
5432
      const aliases = (host.aliases || []).filter(name => String(name || '').toLowerCase() !== alias);
5433
      await api('/api/hosts/upsert', {
5434
        method: 'POST',
5435
        headers: { 'Content-Type': 'application/json' },
5436
        body: JSON.stringify(hostUpsertPayload(host, { aliases })),
5437
      });
5438
      msg(`alias ${alias} sters de pe ${host.fqdn}`);
5439
      await refresh();
5440
    }
5441

            
Bogdan Timofte authored 3 days ago
5442
    async function deleteHostInline(id) {
5443
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
5444
      const host = state.hosts.find(entry => String(entry.id || '') === String(id || ''));
5445
      if (!host) return;
5446
      if (!confirm(`Delete ${host.fqdn || host.id || id}?`)) return;
5447
      await api('/api/hosts/delete', {
5448
        method: 'POST',
5449
        headers: { 'Content-Type': 'application/json' },
5450
        body: JSON.stringify({ id: host.id || id }),
5451
      });
5452
      if (hostEditorTarget === String(host.id || '')) closeHostForm(true);
5453
      msg(`host ${host.fqdn || host.id || id} deleted`);
5454
      await refresh();
5455
    }
5456

            
Bogdan Timofte authored 3 days ago
5457
    async function setHostCertificateFromSelect(select) {
5458
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
5459
        select.value = select.dataset.currentCertificate || '';
5460
        return;
5461
      }
5462
      const hostFqdn = select.dataset.hostCertSelect || '';
5463
      const certificateId = select.value || '';
5464
      const current = select.dataset.currentCertificate || '';
5465
      if (!hostFqdn || certificateId === current) return;
5466
      if (!certificateId && current && !confirm(`Sterg asocierea certificatului de pe ${hostFqdn}?`)) {
5467
        select.value = current;
5468
        return;
5469
      }
5470
      select.disabled = true;
5471
      try {
5472
        await api('/api/hosts/certificate', {
5473
          method: 'POST',
5474
          headers: { 'Content-Type': 'application/json' },
5475
          body: JSON.stringify({ host_fqdn: hostFqdn, certificate_id: certificateId }),
5476
        });
5477
        msg(certificateId ? `certificatul ${certificateId} asociat cu ${hostFqdn}` : `certificatul scos de pe ${hostFqdn}`);
5478
        await refresh();
5479
      } finally {
5480
        select.disabled = false;
5481
      }
5482
    }
5483

            
5484
    async function issueHostCertificate(hostFqdn, currentCertificateId, button) {
5485
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
5486
      if (!hostFqdn) return;
5487
      if (currentCertificateId && !confirm(`Emitem un certificat nou pentru ${hostFqdn} si inlocuim asocierea curenta?`)) return;
5488
      if (button) button.disabled = true;
5489
      try {
5490
        const result = await api('/api/hosts/issue-certificate', {
5491
          method: 'POST',
5492
          headers: { 'Content-Type': 'application/json' },
5493
          body: JSON.stringify({ host_fqdn: hostFqdn }),
5494
        });
5495
        msg(`certificatul ${result.certificate_id || ''} emis pentru ${hostFqdn}`);
5496
        await refresh();
5497
      } finally {
5498
        if (button) button.disabled = false;
5499
      }
5500
    }
5501

            
Bogdan Timofte authored 4 days ago
5502
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
5503
      const vhost = select.dataset.vhostSelect || '';
5504
      const fromHost = select.dataset.currentHost || '';
5505
      const toHost = select.value || '';
5506
      if (!vhost || !toHost || toHost === fromHost) return;
5507
      select.disabled = true;
5508
      try {
5509
        await api('/api/vhosts/reassign', {
5510
          method: 'POST',
5511
          headers: { 'Content-Type': 'application/json' },
5512
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
5513
        });
5514
        msg(`vhost ${vhost} moved`);
5515
        await refresh();
5516
      } finally {
5517
        select.disabled = false;
5518
      }
5519
    }
5520

            
Bogdan Timofte authored 4 days ago
5521
    async function addVhostInline() {
5522
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
5523
      const nameInput = $('vhost-new-name');
5524
      const hostSelect = $('vhost-new-host');
Bogdan Timofte authored a day ago
5525
      const vhost = normalizeVhostInput(nameInput.value || '');
5526
      nameInput.value = vhost;
Bogdan Timofte authored 4 days ago
5527
      const hostFqdn = hostSelect.value || '';
Bogdan Timofte authored 3 days ago
5528
      if (!vhost || !hostFqdn) {
5529
        msg('completeaza vhost si host');
5530
        return;
5531
      }
5532
      if (!isValidVhostName(vhost)) {
5533
        msg('vhost invalid: foloseste un nume sub madagascar.xdev.ro');
5534
        nameInput.focus();
5535
        return;
5536
      }
5537
      if (state.hosts.some(host => (host.fqdn || '').toLowerCase() === vhost)) {
5538
        msg('vhost invalid: numele este deja host real');
5539
        nameInput.focus();
5540
        return;
5541
      }
Bogdan Timofte authored 4 days ago
5542
      $('vhost-add').disabled = true;
5543
      nameInput.disabled = true;
5544
      hostSelect.disabled = true;
5545
      try {
5546
        await api('/api/vhosts/upsert', {
5547
          method: 'POST',
5548
          headers: { 'Content-Type': 'application/json' },
5549
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
5550
        });
5551
        nameInput.value = '';
5552
        msg(`vhost ${vhost} saved`);
5553
        await refresh();
Bogdan Timofte authored 3 days ago
5554
      } catch (e) {
5555
        if (!isAuthLost(e)) msg(vhostErrorMessage(e));
Bogdan Timofte authored 4 days ago
5556
      } finally {
5557
        $('vhost-add').disabled = false;
5558
        nameInput.disabled = false;
5559
        hostSelect.disabled = false;
5560
      }
5561
    }
5562

            
Bogdan Timofte authored 3 days ago
5563
    function isValidVhostName(name) {
5564
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
5565
      if (!(name === 'madagascar.xdev.ro' || name.endsWith('.madagascar.xdev.ro'))) return false;
5566
      if (name.length > 253) return false;
5567
      return name.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label));
5568
    }
5569

            
Bogdan Timofte authored a day ago
5570
    function normalizeVhostInput(name) {
5571
      name = String(name || '').trim().toLowerCase().replace(/\.$/, '');
5572
      if (name && !name.includes('.')) return `${name}.madagascar.xdev.ro`;
5573
      return name;
5574
    }
5575

            
Bogdan Timofte authored 3 days ago
5576
    function vhostErrorMessage(error) {
5577
      const code = error && error.code ? error.code : '';
5578
      if (code === 'invalid_vhost') return 'vhost invalid: foloseste un nume sub madagascar.xdev.ro';
5579
      if (code === 'vhost_matches_host') return 'vhost invalid: numele este deja host real';
5580
      if (code === 'invalid_target_host') return 'host tinta invalid';
5581
      if (code === 'missing_target_host') return 'alege hostul tinta';
5582
      return error && error.message ? error.message : 'vhost add failed';
5583
    }
5584

            
Bogdan Timofte authored 3 days ago
5585
    async function setVhostCertificateFromSelect(select) {
5586
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
5587
        select.value = select.dataset.currentCertificate || '';
5588
        return;
5589
      }
5590
      const vhost = select.dataset.vhostCertSelect || '';
5591
      const certificateId = select.value || '';
5592
      const current = select.dataset.currentCertificate || '';
5593
      if (!vhost || certificateId === current) return;
5594
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
5595
        select.value = current;
5596
        return;
5597
      }
5598
      select.disabled = true;
5599
      try {
5600
        await api('/api/vhosts/certificate', {
5601
          method: 'POST',
5602
          headers: { 'Content-Type': 'application/json' },
5603
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
5604
        });
5605
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
5606
        await refresh();
5607
      } finally {
5608
        select.disabled = false;
5609
      }
5610
    }
5611

            
Bogdan Timofte authored 3 days ago
5612
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 3 days ago
5613
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
5614
      if (!vhost) return;
5615
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
5616
      if (button) button.disabled = true;
5617
      try {
5618
        const result = await api('/api/vhosts/issue-certificate', {
5619
          method: 'POST',
5620
          headers: { 'Content-Type': 'application/json' },
5621
          body: JSON.stringify({ vhost_fqdn: vhost }),
5622
        });
5623
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
5624
        await refresh();
5625
      } finally {
5626
        if (button) button.disabled = false;
5627
      }
5628
    }
5629

            
Bogdan Timofte authored 4 days ago
5630
    async function deleteVhostInline(vhost) {
5631
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
5632
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
5633
      await api('/api/vhosts/delete', {
5634
        method: 'POST',
5635
        headers: { 'Content-Type': 'application/json' },
5636
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
5637
      });
5638
      msg(`vhost ${vhost} deleted`);
5639
      await refresh();
5640
    }
5641

            
Bogdan Timofte authored 4 days ago
5642
    async function editHost(id) {
5643
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
5644
      const host = state.hosts.find(h => h.id === id);
5645
      if (!host) return;
Bogdan Timofte authored 3 days ago
5646
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
5647
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
5648
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
5649
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
5650
      hostField('roles').value = (host.roles || []).join(' ');
Bogdan Timofte authored a day ago
5651
      hostField('tags').value = (host.tags || []).join(' ');
Bogdan Timofte authored 3 days ago
5652
      activateHostForm(`Edit host ${host.fqdn || host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
5653
    }
5654

            
Bogdan Timofte authored a day ago
5655
    async function editHostByFqdn(fqdn) {
5656
      const host = hostByFqdn(fqdn);
5657
      if (!host) return;
5658
      await editHost(host.id);
5659
    }
5660

            
Bogdan Timofte authored 4 days ago
5661
    async function newHost() {
5662
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 3 days ago
5663
      if (!canSwitchHostEditor('__new__')) return;
5664
      resetHostForm(true);
Bogdan Timofte authored 2 days ago
5665
      hostField('id').value = '';
5666
      activateHostForm('New host', 'new', '__new__', 'fqdn');
Bogdan Timofte authored 5 days ago
5667
    }
5668

            
Bogdan Timofte authored a day ago
5669
    async function newHostFromObservation(fqdn, ip) {
5670
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
5671
      if (!canSwitchHostEditor('__new__')) return;
5672
      resetHostForm(true);
5673
      hostField('id').value = '';
5674
      hostField('fqdn').value = normalizeHostFqdn(fqdn || '');
5675
      hostField('ip').value = ip || '';
5676
      syncHostFormId();
5677
      activateHostForm('New host', 'new', '__new__', ip ? 'tags' : 'ip');
5678
      renderHostObservationHints();
5679
    }
5680

            
Bogdan Timofte authored 2 days ago
5681
    function activateHostForm(title, mode, target, focusField = 'fqdn', scroll = true) {
Bogdan Timofte authored 3 days ago
5682
      hostFormMode = mode || 'new';
Bogdan Timofte authored 3 days ago
5683
      hostEditorTarget = target || '';
5684
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 3 days ago
5685
      syncHostFormActions();
Bogdan Timofte authored 3 days ago
5686
      renderHosts();
5687
      hostFormSnapshot = hostFormState();
Bogdan Timofte authored a day ago
5688
      renderHostObservationHints();
Bogdan Timofte authored 3 days ago
5689
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 3 days ago
5690
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
5691
    }
5692

            
Bogdan Timofte authored 3 days ago
5693
    function resetHostForm(force = false) {
Bogdan Timofte authored 3 days ago
5694
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 3 days ago
5695
      hostForm.reset();
Bogdan Timofte authored 5 days ago
5696
      clearHostFormMessage();
Bogdan Timofte authored 3 days ago
5697
      hostField('status').value = 'active';
5698
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 3 days ago
5699
      hostFormSnapshot = force ? '' : hostFormState();
5700
    }
5701

            
5702
    function closeHostForm(force = false) {
5703
      if (hostFormBusy && !force) return;
5704
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
5705
      hostEditorTarget = '';
5706
      hostFormMode = 'new';
5707
      hostFormSnapshot = '';
5708
      clearHostFormMessage();
5709
      syncHostFormActions();
5710
      mountHostEditor();
5711
    }
5712

            
5713
    function canSwitchHostEditor(target) {
5714
      if (hostFormBusy) return false;
5715
      if (!hostEditorTarget) return true;
5716
      if (!hostFormDirty()) return true;
5717
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
5718
      return confirm('Discard unsaved host changes?');
5719
    }
5720

            
5721
    function mountHostEditor() {
5722
      hostEditorRow.remove();
5723
      if (!hostEditorTarget) {
5724
        hostFormShell.hidden = true;
5725
        return;
5726
      }
Bogdan Timofte authored a day ago
5727
      hostEditorCell.colSpan = 9;
Bogdan Timofte authored 3 days ago
5728
      const tbody = $('hosts');
5729
      if (!tbody) return;
5730
      if (hostEditorTarget === '__new__') {
5731
        tbody.prepend(hostEditorRow);
5732
      } else {
5733
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
5734
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
5735
        if (targetRow) targetRow.after(hostEditorRow);
5736
        else tbody.prepend(hostEditorRow);
5737
      }
5738
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
5739
    }
5740

            
5741
    function hostField(name) {
Bogdan Timofte authored 3 days ago
5742
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
5743
    }
5744

            
5745
    function hostFormState() {
Bogdan Timofte authored 3 days ago
5746
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
5747
    }
5748

            
5749
    function hostFormDirty() {
Bogdan Timofte authored 3 days ago
5750
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
5751
    }
5752

            
5753
    function setHostFormBusy(busy) {
Bogdan Timofte authored 3 days ago
5754
      hostFormBusy = !!busy;
5755
      syncHostFormActions();
5756
    }
5757

            
5758
    function syncHostFormActions() {
Bogdan Timofte authored 3 days ago
5759
      saveHostButton.disabled = hostFormBusy;
5760
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
5761
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 3 days ago
5762
      hostAddAliasEditorButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
5763
    }
5764

            
5765
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 3 days ago
5766
      hostFormMessage.textContent = text || '';
5767
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
5768
    }
5769

            
5770
    function clearHostFormMessage() {
5771
      setHostFormMessage('');
Xdev Host Manager authored a week ago
5772
    }
5773

            
5774
    function formObject(form) {
5775
      return Object.fromEntries(new FormData(form).entries());
5776
    }
5777

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

            
Bogdan Timofte authored a day ago
5783
    function attrSelectorValue(value) {
5784
      return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
5785
    }
5786

            
Bogdan Timofte authored 6 days ago
5787
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
5788

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

            
5794
    if (loginAccount) {
5795
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
5796
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
5797
      loginAccount.addEventListener('input', () => {
5798
        const value = (loginAccount.value || '').trim();
5799
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
5800
      });
5801
    }
5802

            
Xdev Host Manager authored a week ago
5803
    function setOtpDigit(idx, value) {
5804
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
5805
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
5806
      otpDigits[idx].classList.toggle('filled', !!digit);
5807
    }
5808

            
Bogdan Timofte authored 4 days ago
5809
    // Move focus to the next empty box: forward from idx, then wrapping to the
5810
    // start. This lets out-of-order entry continue (e.g. after the last box,
5811
    // jump back to the first still-empty box). Stays put when all boxes are full.
5812
    function advanceFocus(idx) {
5813
      for (let i = idx + 1; i < otpDigits.length; i++) {
5814
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
5815
      }
5816
      for (let i = 0; i <= idx; i++) {
5817
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
5818
      }
5819
    }
5820

            
Bogdan Timofte authored 4 days ago
5821
    // Spread multiple digits across boxes starting at startIdx. Used for paste
5822
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
5823
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
5824
      const digits = (text || '').replace(/\D/g, '').split('');
5825
      if (!digits.length) return;
5826
      let last = startIdx;
5827
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
5828
        last = startIdx + i;
5829
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
5830
      }
Bogdan Timofte authored 4 days ago
5831
      syncOtpFields();
Bogdan Timofte authored 4 days ago
5832
      advanceFocus(last);
Xdev Host Manager authored a week ago
5833
      maybeSubmitOtp();
5834
    }
5835

            
Bogdan Timofte authored 4 days ago
5836
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
5837
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
5838
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
5839
    function maybeSubmitOtp() {
5840
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
5841
    }
5842
    function clearOtp() {
Bogdan Timofte authored 4 days ago
5843
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
5844
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
5845
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
5846
      // an unknown operator, so Safari's autofill anchor on the username stays.
5847
      if (loginAccount && !loginAccount.value) loginAccount.focus();
5848
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
5849
    }
5850

            
Bogdan Timofte authored 4 days ago
5851
    otpDigits.forEach((input, idx) => {
5852
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
5853
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5854
        // A single box may receive several digits at once (autofill / typing fast).
5855
        if (input.value.replace(/\D/g, '').length > 1) {
5856
          fillOtp(input.value, idx);
5857
          return;
5858
        }
5859
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
5860
        syncOtpFields();
Bogdan Timofte authored 4 days ago
5861
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
5862
        maybeSubmitOtp();
5863
      });
Bogdan Timofte authored 4 days ago
5864

            
5865
      input.addEventListener('paste', (e) => {
5866
        e.preventDefault();
Bogdan Timofte authored 4 days ago
5867
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5868
        const text = (e.clipboardData || window.clipboardData).getData('text');
5869
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
5870
      });
Bogdan Timofte authored 4 days ago
5871

            
5872
      input.addEventListener('keydown', (e) => {
5873
        if (e.key === 'Backspace') {
5874
          e.preventDefault();
Bogdan Timofte authored 4 days ago
5875
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
5876
          if (input.value) { setOtpDigit(idx, ''); }
5877
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
5878
          syncOtpFields();
5879
        } else if (e.key === 'ArrowLeft' && idx > 0) {
5880
          e.preventDefault();
5881
          otpDigits[idx - 1].focus();
5882
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
5883
          e.preventDefault();
5884
          otpDigits[idx + 1].focus();
5885
        }
5886
      });
5887
    });
5888

            
Bogdan Timofte authored 4 days ago
5889
    // Focus the first OTP box only for a returning operator (username known).
5890
    // For an unknown operator, leave focus on the username field so Safari can
5891
    // present its OTP autofill anchored there without being dismissed by a focus
5892
    // change (pbx-admin pattern).
5893
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
5894
    else if (loginAccount) loginAccount.focus();
5895
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
5896

            
Bogdan Timofte authored 5 days ago
5897
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
5898
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
5899
        event.preventDefault();
Bogdan Timofte authored 4 days ago
5900
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
5901
        showPage(link.dataset.pageLink, true);
5902
      });
5903
    });
5904

            
Bogdan Timofte authored 4 days ago
5905
    window.addEventListener('popstate', () => {
5906
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
5907
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
5908
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
5909
    });
Bogdan Timofte authored 5 days ago
5910

            
Bogdan Timofte authored 4 days ago
5911
    async function copyText(text) {
5912
      if (navigator.clipboard && window.isSecureContext) {
5913
        await navigator.clipboard.writeText(text);
5914
        return;
5915
      }
5916
      const input = document.createElement('textarea');
5917
      input.value = text;
5918
      input.setAttribute('readonly', '');
5919
      input.style.position = 'fixed';
5920
      input.style.left = '-10000px';
5921
      document.body.appendChild(input);
5922
      input.select();
5923
      document.execCommand('copy');
5924
      document.body.removeChild(input);
5925
    }
5926

            
5927
    $('copy-build').addEventListener('click', async () => {
5928
      try {
5929
        await copyText($('copy-build').dataset.buildDetails || '');
5930
        if (state.authenticated) msg('build details copied');
5931
      } catch (e) {
5932
        if (state.authenticated) msg('copy failed');
5933
      }
5934
    });
5935

            
Xdev Host Manager authored a week ago
5936
    $('login-form').addEventListener('submit', async (event) => {
5937
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5938
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
5939
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
5940
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
5941
      try {
Xdev Host Manager authored a week ago
5942
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
5943
        await refresh();
Xdev Host Manager authored a week ago
5944
      } catch (e) {
5945
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
5946
      } finally {
Xdev Host Manager authored a week ago
5947
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
5948
      }
Xdev Host Manager authored a week ago
5949
    });
5950

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

            
Bogdan Timofte authored 4 days ago
5956
    $('refresh').addEventListener('click', () => refresh().catch(e => {
5957
      if (!isAuthLost(e)) msg(e.message);
5958
    }));
Xdev Host Manager authored a week ago
5959
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
5960
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored a day ago
5961
    $('vhost-new-name').addEventListener('input', renderVhostObservationHints);
Bogdan Timofte authored 4 days ago
5962
    $('vhost-add').addEventListener('click', () => {
5963
      addVhostInline().catch(e => {
5964
        if (!isAuthLost(e)) msg(e.message);
5965
      });
5966
    });
5967
    $('vhost-new-name').addEventListener('keydown', (event) => {
5968
      if (event.key !== 'Enter') return;
5969
      event.preventDefault();
5970
      addVhostInline().catch(e => {
5971
        if (!isAuthLost(e)) msg(e.message);
5972
      });
5973
    });
Bogdan Timofte authored 4 days ago
5974
    $('new-host').addEventListener('click', () => {
5975
      newHost().catch(e => {
5976
        if (!isAuthLost(e)) msg(e.message);
5977
      });
5978
    });
Bogdan Timofte authored 4 days ago
5979
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
5980
      if (!isAuthLost(e)) msg(e.message);
5981
    }));
Bogdan Timofte authored 3 days ago
5982
    hostAddAliasEditorButton.addEventListener('click', appendAliasInEditor);
Bogdan Timofte authored 3 days ago
5983
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
5984

            
Bogdan Timofte authored 3 days ago
5985
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
5986
      event.preventDefault();
Bogdan Timofte authored 4 days ago
5987
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 2 days ago
5988
      syncHostFormId();
Bogdan Timofte authored 5 days ago
5989
      setHostFormBusy(true);
5990
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
5991
      try {
5992
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
5993
        msg('host saved');
5994
        await refresh();
Bogdan Timofte authored 3 days ago
5995
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
5996
      } catch (e) {
Bogdan Timofte authored 4 days ago
5997
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
5998
        setHostFormMessage(e.message, true);
5999
        msg(e.message);
6000
      } finally {
6001
        setHostFormBusy(false);
6002
      }
6003
    });
6004

            
Bogdan Timofte authored 3 days ago
6005
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
6006
      setHostFormMessage('Complete the required host fields before saving.', true);
6007
    }, true);
6008

            
Bogdan Timofte authored 2 days ago
6009
    hostForm.addEventListener('input', (event) => {
6010
      if (hostFormMode === 'new' && event.target && event.target.name === 'fqdn') syncHostFormId();
Bogdan Timofte authored 3 days ago
6011
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Bogdan Timofte authored a day ago
6012
      if (event.target && (event.target.name === 'fqdn' || event.target.name === 'ip')) renderHostObservationHints();
Xdev Host Manager authored a week ago
6013
    });
6014

            
Bogdan Timofte authored 3 days ago
6015
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 2 days ago
6016
      const id = hostField('id').value || normalizeHostFqdn(hostField('fqdn').value || '');
Xdev Host Manager authored a week ago
6017
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
6018
      setHostFormBusy(true);
6019
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
6020
      try {
6021
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
6022
        msg('host deleted');
6023
        await refresh();
Bogdan Timofte authored 3 days ago
6024
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
6025
      } catch (e) {
Bogdan Timofte authored 4 days ago
6026
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
6027
        setHostFormMessage(e.message, true);
6028
        msg(e.message);
6029
      } finally {
6030
        setHostFormBusy(false);
6031
      }
Xdev Host Manager authored a week ago
6032
    });
6033

            
Bogdan Timofte authored 3 days ago
6034
    resetHostForm(true);
6035
    closeHostForm(true);
Bogdan Timofte authored 3 days ago
6036

            
Bogdan Timofte authored a day ago
6037
    $('tag-add').addEventListener('click', () => {
6038
      addOrUpdateTag($('tag-new-label').value || '', $('tag-new-color').value || '#647084', $('tag-new-icon').value || 'tag').catch(e => {
6039
        if (!isAuthLost(e)) msg(e.message);
6040
      });
6041
    });
6042

            
6043
    $('publish-dns').addEventListener('click', async () => {
6044
      if (!confirm('Queue resolver sync from the runtime registry?')) return;
Xdev Host Manager authored a week ago
6045
      try {
Bogdan Timofte authored a day ago
6046
        await api('/api/dns/publish', { method: 'POST' });
6047
        msg('resolver sync queued');
Bogdan Timofte authored 4 days ago
6048
      } catch (e) {
6049
        if (!isAuthLost(e)) msg(e.message);
6050
      }
Xdev Host Manager authored a week ago
6051
    });
6052

            
Bogdan Timofte authored 4 days ago
6053
    refresh().catch(e => {
6054
      if (!isAuthLost(e)) showLogin(e.message);
6055
    });
Xdev Host Manager authored a week ago
6056
  </script>
6057
</body>
6058
</html>
6059
HTML
Bogdan Timofte authored 6 days ago
6060
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
6061
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
6062
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
6063
    return $html;
Xdev Host Manager authored a week ago
6064
}