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

            
6
use strict;
7
use warnings;
8

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

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

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

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

            
52
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
53
my %sessions;
54

            
55
my $server = IO::Socket::INET->new(
56
    LocalHost => $opt{bind},
57
    LocalPort => $opt{port},
58
    Proto => 'tcp',
59
    Listen => 10,
60
    ReuseAddr => 1,
61
) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
62

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

            
68
while (my $client = $server->accept) {
69
    eval {
70
        $client->autoflush(1);
71
        handle_client($client);
72
    };
73
    if ($@) {
74
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
75
    }
76
    close $client;
77
}
78

            
79
sub usage {
80
    print <<"EOF";
81
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
82

            
83
Environment:
84
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
85
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
Bogdan Timofte authored 4 days ago
86
  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
Xdev Host Manager authored a week ago
87
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
88
  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
Xdev Host Manager authored a week ago
89
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Xdev Host Manager authored a week ago
90

            
Bogdan Timofte authored 4 days ago
91
SQLite is the runtime source of truth. YAML files seed a new database and remain
92
download/export compatibility artifacts. The nginx vhost keeps registry, CA,
93
work order and download endpoints behind OTP.
Xdev Host Manager authored a week ago
94
EOF
95
}
96

            
97
sub handle_client {
98
    my ($client) = @_;
99
    my $request_line = <$client>;
100
    return unless defined $request_line;
101
    $request_line =~ s/\r?\n$//;
102
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
103
    return send_text($client, 400, 'bad request') unless $method && $target;
104

            
105
    my %headers;
106
    while (my $line = <$client>) {
107
        $line =~ s/\r?\n$//;
108
        last if $line eq '';
109
        my ($k, $v) = split /:\s*/, $line, 2;
110
        $headers{lc $k} = $v if defined $k && defined $v;
111
    }
112

            
113
    my $body = '';
114
    if (($headers{'content-length'} || 0) > 0) {
115
        read($client, $body, int($headers{'content-length'}));
116
    }
117

            
118
    my ($path, $query) = split /\?/, $target, 2;
119
    my %query = parse_params($query || '');
120

            
Bogdan Timofte authored 5 days ago
121
    if ($method eq 'GET' && app_page_path($path)) {
Xdev Host Manager authored a week ago
122
        return send_html($client, 200, app_html());
123
    }
124
    if ($method eq 'GET' && $path eq '/healthz') {
Xdev Host Manager authored a week ago
125
        return send_json($client, 200, { ok => json_bool(1) });
Xdev Host Manager authored a week ago
126
    }
127
    if ($method eq 'GET' && $path eq '/api/session') {
128
        return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
129
    }
Xdev Host Manager authored a week ago
130
    if ($method eq 'POST' && $path eq '/api/login') {
131
        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
132
        my $payload = request_payload(\%headers, $body);
133
        my $otp = $payload->{otp} || '';
134
        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
135
            return send_json($client, 401, { error => 'invalid_otp' });
136
        }
137
        my $token = create_session();
138
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
139
    }
140
    if ($method eq 'POST' && $path eq '/api/logout') {
141
        expire_session(\%headers);
142
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
143
    }
144

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

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

            
200
    if ($method eq 'POST' && $path =~ m{^/api/}) {
201
        if ($path eq '/api/hosts/upsert') {
202
            my $payload = request_payload(\%headers, $body);
203
            return upsert_host($client, $payload);
204
        }
205
        if ($path eq '/api/hosts/delete') {
206
            my $payload = request_payload(\%headers, $body);
207
            return delete_host($client, $payload->{id} || '');
208
        }
Bogdan Timofte authored 4 days ago
209
        if ($path eq '/api/vhosts/reassign') {
210
            my $payload = request_payload(\%headers, $body);
211
            return reassign_vhost($client, $payload);
212
        }
Bogdan Timofte authored 4 days ago
213
        if ($path eq '/api/vhosts/upsert') {
214
            my $payload = request_payload(\%headers, $body);
215
            return upsert_vhost($client, $payload);
216
        }
217
        if ($path eq '/api/vhosts/delete') {
218
            my $payload = request_payload(\%headers, $body);
219
            return delete_vhost($client, $payload);
220
        }
Bogdan Timofte authored 4 days ago
221
        if ($path eq '/api/vhosts/certificate') {
222
            my $payload = request_payload(\%headers, $body);
223
            return set_vhost_certificate($client, $payload);
224
        }
225
        if ($path eq '/api/vhosts/issue-certificate') {
226
            my $payload = request_payload(\%headers, $body);
227
            return issue_vhost_certificate($client, $payload);
228
        }
Xdev Host Manager authored a week ago
229
        if ($path eq '/api/work-orders/confirm') {
230
            my $payload = request_payload(\%headers, $body);
231
            return confirm_work_order($client, $payload);
232
        }
Xdev Host Manager authored a week ago
233
        if ($path eq '/api/work-orders/checklist') {
234
            my $payload = request_payload(\%headers, $body);
235
            return update_work_order_checklist($client, $payload);
236
        }
Xdev Host Manager authored a week ago
237
        if ($path eq '/api/render/local-hosts-tsv') {
238
            my $registry = load_registry();
239
            my $content = render_local_hosts_tsv($registry);
240
            backup_file($opt{local_hosts_tsv});
241
            write_file($opt{local_hosts_tsv}, $content);
242
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
243
        }
244
    }
245

            
246
    return send_json($client, 404, { error => 'not_found' });
247
}
248

            
Bogdan Timofte authored 5 days ago
249
sub app_page_path {
250
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
251
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
252
}
253

            
Xdev Host Manager authored a week ago
254
sub load_registry {
Bogdan Timofte authored 4 days ago
255
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
256
    normalize_registry_policy($registry);
257
    return $registry;
Xdev Host Manager authored a week ago
258
}
259

            
260
sub save_registry {
261
    my ($registry) = @_;
262
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
263
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
264
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
265
}
266

            
Xdev Host Manager authored a week ago
267
sub load_work_orders {
Bogdan Timofte authored 4 days ago
268
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
269
}
270

            
271
sub save_work_orders {
272
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
273
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
274
}
275

            
276
sub work_orders_payload {
277
    my ($orders) = @_;
278
    my $pending = 0;
279
    for my $wo (@{ $orders->{work_orders} || [] }) {
280
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
281
    }
282
    return {
283
        version => $orders->{version},
284
        work_orders => $orders->{work_orders} || [],
285
        counts => {
286
            work_orders => scalar @{ $orders->{work_orders} || [] },
287
            pending => $pending,
288
        },
289
    };
290
}
291

            
292
sub confirm_work_order {
293
    my ($client, $payload) = @_;
294
    my $id = clean_scalar($payload->{id} || '');
295
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
296
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
297

            
298
    my $orders = load_work_orders();
299
    my $work_order;
300
    for my $wo (@{ $orders->{work_orders} || [] }) {
301
        if (($wo->{id} || '') eq $id) {
302
            $work_order = $wo;
303
            last;
304
        }
305
    }
306
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
307
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
308
    my $incomplete = incomplete_work_order_items($work_order);
309
    return send_json($client, 409, {
310
        error => 'work_order_incomplete',
311
        incomplete => $incomplete,
312
    }) if @$incomplete;
Xdev Host Manager authored a week ago
313

            
314
    my $registry = load_registry();
315
    my $results = apply_work_order($registry, $work_order);
316
    $work_order->{status} = 'confirmed';
317
    $work_order->{confirmed_at} = iso_now();
318
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
319

            
320
    save_registry($registry);
321
    save_work_orders($orders);
322
    backup_file($opt{local_hosts_tsv});
323
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
324

            
325
    return send_json($client, 200, {
326
        ok => json_bool(1),
327
        work_order => $work_order,
328
        results => $results,
329
        local_hosts_tsv => $opt{local_hosts_tsv},
330
    });
331
}
332

            
Xdev Host Manager authored a week ago
333
sub update_work_order_checklist {
334
    my ($client, $payload) = @_;
335
    my $id = clean_scalar($payload->{id} || '');
336
    my $item_id = clean_scalar($payload->{item_id} || '');
337
    my $status = clean_scalar($payload->{status} || '');
338
    my $notes = clean_scalar($payload->{notes} || '');
339
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
340
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
341
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
342

            
343
    my $orders = load_work_orders();
344
    my $work_order;
345
    for my $wo (@{ $orders->{work_orders} || [] }) {
346
        if (($wo->{id} || '') eq $id) {
347
            $work_order = $wo;
348
            last;
349
        }
350
    }
351
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
352
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
353

            
354
    my $item;
355
    for my $candidate (@{ $work_order->{checklist} || [] }) {
356
        if (($candidate->{id} || '') eq $item_id) {
357
            $item = $candidate;
358
            last;
359
        }
360
    }
361
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
362

            
363
    $item->{status} = $status;
364
    $item->{updated_at} = iso_now();
365
    $item->{notes} = $notes if length $notes;
366
    save_work_orders($orders);
367
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
368
}
369

            
370
sub incomplete_work_order_items {
371
    my ($work_order) = @_;
372
    my @incomplete;
373
    for my $item (@{ $work_order->{checklist} || [] }) {
374
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
375
    }
376
    return \@incomplete;
377
}
378

            
Xdev Host Manager authored a week ago
379
sub apply_work_order {
380
    my ($registry, $work_order) = @_;
381
    my @results;
382
    for my $action (@{ $work_order->{actions} || [] }) {
383
        my $type = $action->{type} || '';
384
        if ($type eq 'remove_name') {
385
            my $host_id = $action->{host_id} || '';
386
            my $name = $action->{name} || '';
387
            my $removed = 0;
388
            for my $host (@{ $registry->{hosts} || [] }) {
389
                next unless ($host->{id} || '') eq $host_id;
Bogdan Timofte authored 4 days ago
390
                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
391
                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
392
                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
393
                $host->{aliases} = \@kept_aliases;
394
                $host->{vhosts} = \@kept_vhosts;
Xdev Host Manager authored a week ago
395
                last;
396
            }
397
            push @results, {
398
                type => $type,
399
                host_id => $host_id,
400
                name => $name,
401
                removed => json_bool($removed),
402
            };
403
        } else {
404
            die "Unsupported work order action: $type\n";
405
        }
406
    }
407
    return \@results;
408
}
409

            
Xdev Host Manager authored a week ago
410
sub registry_payload {
411
    my ($registry) = @_;
412
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
413
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Bogdan Timofte authored 4 days ago
414
    my $dbh = dbh();
415
    my @vhosts = vhost_payloads($dbh);
416
    my @certificates = certificate_payloads($dbh);
Bogdan Timofte authored 4 days ago
417
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
418
    return {
419
        version => $registry->{version},
420
        updated_at => $registry->{updated_at},
421
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
422
        hosts => \@hosts,
Bogdan Timofte authored 4 days ago
423
        vhosts => \@vhosts,
424
        certificates => \@certificates,
Xdev Host Manager authored a week ago
425
        problems => $problems,
426
        counts => {
427
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 4 days ago
428
            vhosts => scalar(@vhosts) || $vhost_count,
Xdev Host Manager authored a week ago
429
            problems => scalar @$problems,
430
        },
431
    };
432
}
433

            
Bogdan Timofte authored 4 days ago
434
sub vhost_payloads {
435
    my ($dbh) = @_;
436
    my @rows;
437
    my $sth = $dbh->prepare(<<'SQL');
438
SELECT
439
    v.vhost_fqdn,
440
    v.host_fqdn,
441
    v.status AS vhost_status,
442
    v.certificate_id,
443
    h.legacy_id,
444
    h.hosts_ip,
445
    h.dns_ip,
446
    h.monitoring,
447
    h.status AS host_status,
448
    c.common_name,
449
    c.not_after,
450
    c.fingerprint_sha256,
451
    c.status AS certificate_status
452
FROM vhosts v
453
JOIN hosts h ON h.fqdn = v.host_fqdn
454
LEFT JOIN certificates c ON c.certificate_id = v.certificate_id
455
WHERE v.status = 'active'
456
ORDER BY v.vhost_fqdn
457
SQL
458
    $sth->execute;
459
    while (my $row = $sth->fetchrow_hashref) {
460
        my $cert_id = clean_scalar($row->{certificate_id} || '');
461
        my %certificate = $cert_id ? (
462
            id => $cert_id,
463
            name => $cert_id,
464
            common_name => clean_scalar($row->{common_name} || ''),
465
            status => clean_scalar($row->{certificate_status} || ''),
466
            not_after => clean_scalar($row->{not_after} || ''),
467
            fingerprint_sha256 => clean_scalar($row->{fingerprint_sha256} || ''),
Bogdan Timofte authored 4 days ago
468
            has_private_key => json_bool(ca_private_key_exists($cert_id)),
Bogdan Timofte authored 4 days ago
469
        ) : ();
470
        push @rows, {
471
            vhost => $row->{vhost_fqdn},
472
            vhost_fqdn => $row->{vhost_fqdn},
473
            host_id => $row->{legacy_id} || '',
474
            host_fqdn => $row->{host_fqdn},
475
            ip => $row->{hosts_ip} || $row->{dns_ip} || '',
476
            derived_aliases => short_alias_for_fqdn($row->{vhost_fqdn}) ? [ short_alias_for_fqdn($row->{vhost_fqdn}) ] : [],
477
            monitoring => $row->{monitoring} || '',
478
            status => $row->{host_status} || $row->{vhost_status} || '',
479
            vhost_status => $row->{vhost_status} || '',
480
            certificate_id => $cert_id,
481
            certificate => $cert_id ? \%certificate : undef,
482
        };
483
    }
484
    return @rows;
485
}
486

            
487
sub certificate_payloads {
488
    my ($dbh) = @_;
489
    my @certificates;
490
    my $sth = $dbh->prepare('SELECT * FROM certificates WHERE status <> ? ORDER BY certificate_id');
491
    $sth->execute('retired');
492
    while (my $row = $sth->fetchrow_hashref) {
493
        my $id = clean_scalar($row->{certificate_id} || '');
494
        next unless $id;
495
        push @certificates, {
496
            id => $id,
497
            name => $id,
498
            host_fqdn => $row->{host_fqdn} || '',
499
            common_name => $row->{common_name} || '',
500
            subject => $row->{subject} || '',
501
            issuer => $row->{issuer} || '',
502
            serial => $row->{serial} || '',
503
            status => $row->{status} || '',
504
            not_before => $row->{not_before} || '',
505
            not_after => $row->{not_after} || '',
506
            fingerprint_sha256 => $row->{fingerprint_sha256} || '',
507
            dns_names => [ certificate_dns_names($dbh, $id) ],
Bogdan Timofte authored 4 days ago
508
            has_private_key => json_bool(ca_private_key_exists($id)),
Bogdan Timofte authored 4 days ago
509
        };
510
    }
511
    return @certificates;
512
}
513

            
514
sub certificate_dns_names {
515
    my ($dbh, $certificate_id) = @_;
516
    my @names;
517
    my $sth = $dbh->prepare('SELECT dns_name FROM certificate_dns_names WHERE certificate_id = ? ORDER BY dns_name');
518
    $sth->execute($certificate_id);
519
    while (my ($name) = $sth->fetchrow_array) {
520
        push @names, $name;
521
    }
522
    return @names;
523
}
524

            
Xdev Host Manager authored a week ago
525
sub upsert_host {
526
    my ($client, $payload) = @_;
527
    my $id = clean_id($payload->{id} || '');
528
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
529

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

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

            
537
    my $registry = load_registry();
Bogdan Timofte authored 4 days ago
538
    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
539
    my @vhosts = defined $payload->{vhosts}
540
        ? clean_vhost_names($payload)
541
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
542
    my %host = (
543
        id => $id,
Bogdan Timofte authored 4 days ago
544
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
545
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
546
        ip => $ip,
547
        aliases => \@aliases,
548
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
549
        roles => [ clean_list($payload->{roles}) ],
550
        sources => [ clean_list($payload->{sources}) ],
551
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
552
        notes => clean_scalar($payload->{notes} || ''),
553
    );
554

            
Bogdan Timofte authored 4 days ago
555
    my $response = eval {
556
        my $replaced = 0;
557
        for my $i (0 .. $#{ $registry->{hosts} }) {
558
            if ($registry->{hosts}->[$i]{id} eq $id) {
559
                $registry->{hosts}->[$i] = \%host;
560
                $replaced = 1;
561
                last;
562
            }
Xdev Host Manager authored a week ago
563
        }
Bogdan Timofte authored 4 days ago
564
        push @{ $registry->{hosts} }, \%host unless $replaced;
565
        save_registry($registry);
566
        1;
567
    };
568
    if (!$response) {
569
        my $err = $@ || 'upsert_failed';
570
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
571
            if $err =~ /alias_conflict:/;
572
        die $err;
Xdev Host Manager authored a week ago
573
    }
574
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
575
}
576

            
577
sub delete_host {
578
    my ($client, $id) = @_;
579
    $id = clean_id($id);
580
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
581

            
582
    my $registry = load_registry();
583
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
584
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
585
    $registry->{hosts} = \@kept;
586
    save_registry($registry);
587
    return send_json($client, 200, { ok => json_bool(1) });
588
}
589

            
Bogdan Timofte authored 4 days ago
590
sub reassign_vhost {
591
    my ($client, $payload) = @_;
592
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
593
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
594
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
595
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
596

            
597
    my $dbh = dbh();
598
    my ($current_fqdn) = $dbh->selectrow_array(
599
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
600
        undef,
601
        $vhost,
602
    );
603
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
604
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
605
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
606

            
607
    my $result = eval {
608
        with_transaction($dbh, sub {
609
            my $now = iso_now();
610
            $dbh->do(
611
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
612
                undef,
613
                $target_fqdn, $now, $vhost,
614
            );
615

            
616
            my $registry = load_registry_from_db();
617
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
618
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
619

            
620
            upsert_host_to_db($dbh, $target_host) if $target_host;
621
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
622
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
623
        });
624
        1;
625
    };
626
    if (!$result) {
627
        my $err = $@ || 'vhost_reassign_failed';
628
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
629
    }
630
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
631
}
632

            
Bogdan Timofte authored 4 days ago
633
sub upsert_vhost {
634
    my ($client, $payload) = @_;
635
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
636
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
637
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
638
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
639

            
640
    my $dbh = dbh();
641
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
642
    my ($current_fqdn) = $dbh->selectrow_array(
643
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
644
        undef,
645
        $vhost,
646
    );
647

            
648
    my $result = eval {
649
        with_transaction($dbh, sub {
650
            my $now = iso_now();
651
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
652

            
653
            my $registry = load_registry_from_db();
654
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
655
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
656

            
657
            upsert_host_to_db($dbh, $target_host) if $target_host;
658
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
659
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
660
        });
661
        1;
662
    };
663
    if (!$result) {
664
        my $err = $@ || 'vhost_upsert_failed';
665
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
666
    }
667
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
668
}
669

            
670
sub delete_vhost {
671
    my ($client, $payload) = @_;
672
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
673
    my $confirm = normalize_dns_name($payload->{confirm} || '');
674
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
675
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
676

            
677
    my $dbh = dbh();
678
    my ($current_fqdn) = $dbh->selectrow_array(
679
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
680
        undef,
681
        $vhost,
682
    );
683
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
684

            
685
    my $result = eval {
686
        with_transaction($dbh, sub {
687
            my $now = iso_now();
688
            $dbh->do(
689
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
690
                undef,
691
                $now, $vhost,
692
            );
693

            
694
            my $registry = load_registry_from_db();
695
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
696
            upsert_host_to_db($dbh, $current_host) if $current_host;
697
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
698
        });
699
        1;
700
    };
701
    if (!$result) {
702
        my $err = $@ || 'vhost_delete_failed';
703
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
704
    }
705
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
706
}
707

            
Bogdan Timofte authored 4 days ago
708
sub set_vhost_certificate {
709
    my ($client, $payload) = @_;
710
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
711
    my $raw_certificate_id = clean_scalar($payload->{certificate_id} || $payload->{cert_id} || '');
712
    my $certificate_id = clean_certificate_id($raw_certificate_id);
713
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
714
    return send_json($client, 400, { error => 'invalid_certificate' })
715
        if length($raw_certificate_id) && !length($certificate_id);
716

            
717
    my $dbh = dbh();
718
    return send_json($client, 404, { error => 'vhost_not_found' })
719
        unless db_scalar($dbh, "SELECT COUNT(*) FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'", $vhost);
720
    if (length $certificate_id) {
721
        return send_json($client, 400, { error => 'invalid_certificate' })
722
            unless db_scalar($dbh, "SELECT COUNT(*) FROM certificates WHERE certificate_id = ? AND status <> 'retired'", $certificate_id);
723
    }
724

            
725
    my $now = iso_now();
726
    $dbh->do(
727
        'UPDATE vhosts SET certificate_id = ?, tls_mode = ?, updated_at = ? WHERE vhost_fqdn = ? AND status = ?',
728
        undef,
729
        length($certificate_id) ? $certificate_id : undef,
730
        length($certificate_id) ? 'local-ca' : 'none',
731
        $now,
732
        $vhost,
733
        'active',
734
    );
735
    set_schema_meta($dbh, 'registry_updated_at', $now);
736
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, certificate_id => $certificate_id });
737
}
738

            
739
sub issue_vhost_certificate {
740
    my ($client, $payload) = @_;
741
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
742
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
743

            
744
    my $dbh = dbh();
745
    my ($host_fqdn) = $dbh->selectrow_array(
746
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
747
        undef,
748
        $vhost,
749
    );
750
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $host_fqdn;
751

            
752
    my @dns_names = unique_preserve(grep { length $_ } ($vhost, short_alias_for_fqdn($vhost)));
753
    my $certificate_id = clean_certificate_id($vhost . '-' . strftime('%Y%m%d%H%M%S', localtime));
754
    my $issued = eval {
755
        ca_manager_output('issue', $certificate_id, @dns_names);
756
        ca_manager_json('list-json');
757
        with_transaction($dbh, sub {
758
            my $now = iso_now();
759
            $dbh->do(
760
                "UPDATE vhosts SET certificate_id = ?, tls_mode = 'local-ca', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
761
                undef,
762
                $certificate_id,
763
                $now,
764
                $vhost,
765
            );
766
            set_schema_meta($dbh, 'registry_updated_at', $now);
767
        });
768
        1;
769
    };
770
    if (!$issued) {
771
        return send_json($client, 409, { error => 'certificate_issue_failed', detail => clean_scalar($@ || '') });
772
    }
773

            
774
    my ($cert) = grep { ($_->{id} || '') eq $certificate_id } certificate_payloads($dbh);
775
    return send_json($client, 200, {
776
        ok => json_bool(1),
777
        vhost_fqdn => $vhost,
778
        host_fqdn => $host_fqdn,
779
        certificate_id => $certificate_id,
780
        certificate => $cert || { id => $certificate_id, name => $certificate_id, dns_names => \@dns_names },
781
    });
782
}
783

            
Xdev Host Manager authored a week ago
784
sub analyze_hosts {
785
    my ($hosts) = @_;
786
    my @problems;
787
    my (%names, %ids);
788
    for my $host (@$hosts) {
789
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
790
        my $fqdn = canonical_host_fqdn($host);
791
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
792
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
793
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
794
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
795
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
796
            if grep { /^(is|vad|b)-/ } @declared;
797
        for my $name (@declared) {
Xdev Host Manager authored a week ago
798
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
799
        }
Bogdan Timofte authored 4 days ago
800
        my %declared = map { $_ => 1 } @declared;
801
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
802
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
803
                if $declared{$derived};
804
        }
Bogdan Timofte authored 4 days ago
805
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
806
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
807
    }
808
    return \@problems;
809
}
810

            
Xdev Host Manager authored a week ago
811
sub host_payload {
812
    my ($host) = @_;
813
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
814
    $copy{fqdn} = canonical_host_fqdn($host);
815
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
816
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
817
    $copy{declared_names} = [ declared_dns_names($host) ];
818
    $copy{aliases} = [ declared_alias_names($host) ];
819
    $copy{derived_aliases} = [ derived_alias_names($host) ];
820
    $copy{vhosts} = [ declared_vhost_names($host) ];
821
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Xdev Host Manager authored a week ago
822
    return \%copy;
823
}
824

            
825
sub effective_names {
826
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
827
    my @names = declared_dns_names($host);
828
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
829
    return unique_preserve(@names);
830
}
831

            
Bogdan Timofte authored 4 days ago
832
sub declared_dns_names {
833
    my ($host) = @_;
834
    my @names;
835
    my $fqdn = canonical_host_fqdn($host);
836
    push @names, $fqdn if length $fqdn;
837
    push @names, declared_alias_names($host);
838
    push @names, declared_vhost_names($host);
839
    return unique_preserve(@names);
840
}
841

            
842
sub declared_alias_names {
843
    my ($host) = @_;
844
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
845
}
846

            
847
sub declared_vhost_names {
848
    my ($host) = @_;
849
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
850
}
851

            
852
sub declared_dns_names_legacy {
853
    my ($host) = @_;
854
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
855
}
856

            
857
sub split_legacy_names {
858
    my ($id, $names) = @_;
859
    my $fallback = clean_id($id || '');
860
    my (%result) = (
861
        fqdn => '',
862
        aliases => [],
863
        vhosts => [],
864
    );
865
    for my $name (map { normalize_dns_name($_) } @$names) {
866
        next unless length $name;
867
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
868
            $result{fqdn} = $name;
869
            next;
870
        }
871
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
872
            $result{fqdn} = $name;
873
            next;
874
        }
875
        if (name_is_vhost($name)) {
876
            push @{ $result{vhosts} }, $name;
877
        } else {
878
            push @{ $result{aliases} }, $name;
879
        }
880
    }
881
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
882
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
883
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
884
    return \%result;
885
}
886

            
887
sub derived_alias_names {
Xdev Host Manager authored a week ago
888
    my ($host) = @_;
889
    my @derived;
Bogdan Timofte authored 4 days ago
890
    my $fqdn = canonical_host_fqdn($host);
891
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
892
    for my $name (declared_alias_names($host)) {
893
        push @derived, short_alias_for_fqdn($name);
894
    }
895
    return unique_preserve(grep { length $_ } @derived);
896
}
897

            
898
sub derived_vhost_alias_names {
899
    my ($host) = @_;
900
    my @derived;
901
    for my $name (declared_vhost_names($host)) {
902
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
903
    }
Bogdan Timofte authored 4 days ago
904
    return unique_preserve(grep { length $_ } @derived);
905
}
906

            
907
sub clean_alias_names {
908
    my ($payload) = @_;
909
    return clean_name_bucket($payload->{aliases})
910
        if defined $payload->{aliases};
911
    my @legacy = remove_derived_names(clean_list($payload->{names}));
912
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
913
}
914

            
915
sub clean_vhost_names {
916
    my ($payload) = @_;
917
    return clean_name_bucket($payload->{vhosts})
918
        if defined $payload->{vhosts};
919
    my @legacy = remove_derived_names(clean_list($payload->{names}));
920
    return grep { name_is_vhost($_) } @legacy;
921
}
922

            
923
sub clean_name_bucket {
924
    my ($value) = @_;
925
    my @names = clean_list($value);
926
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
927
}
928

            
929
sub remove_derived_names {
930
    my @names = @_;
931
    my %derived;
932
    for my $name (@names) {
933
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
934
        $derived{$1} = 1;
935
    }
936
    return grep { !$derived{$_} } @names;
937
}
938

            
939
sub unique_preserve {
940
    my @values = @_;
941
    my %seen;
942
    return grep { !$seen{$_}++ } @values;
943
}
944

            
Bogdan Timofte authored 4 days ago
945
sub canonical_ip {
946
    my ($host) = @_;
947
    return '' unless $host && ref($host) eq 'HASH';
948
    for my $key (qw(ip dns_ip hosts_ip)) {
949
        my $value = clean_scalar($host->{$key} || '');
950
        return $value if length $value;
951
    }
952
    return '';
953
}
954

            
Xdev Host Manager authored a week ago
955
sub problem {
956
    my ($host, $code, $message) = @_;
957
    return { host_id => $host->{id}, code => $code, message => $message };
958
}
959

            
960
sub render_local_hosts_tsv {
961
    my ($registry) = @_;
962
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
963
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
964
    $out .= "#\n";
965
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
966
    $out .= "# ip<TAB>name [aliases...]\n";
Xdev Host Manager authored a week ago
967
    $out .= "#\n";
968
    $out .= "# Priority rule:\n";
969
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
970
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
971
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
972
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
973
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
974
        my $ip = canonical_ip($host);
975
        next unless $ip;
Xdev Host Manager authored a week ago
976
        my @names = effective_names($host);
977
        next unless @names;
Bogdan Timofte authored 4 days ago
978
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
979
    }
980
    return $out;
981
}
982

            
983
sub render_monitoring {
984
    my ($registry) = @_;
985
    my @hosts;
986
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
987
        next unless ($host->{status} || 'active') eq 'active';
988
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
989
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
990
        push @hosts, {
991
            id => $host->{id},
Xdev Host Manager authored a week ago
992
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
993
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
994
            aliases => \@names,
Bogdan Timofte authored 4 days ago
995
            fqdn => canonical_host_fqdn($host),
996
            declared_names => [ declared_dns_names($host) ],
997
            aliases_declared => [ declared_alias_names($host) ],
998
            aliases_derived => [ derived_alias_names($host) ],
999
            vhosts_declared => [ declared_vhost_names($host) ],
1000
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
1001
            roles => [ @{ $host->{roles} || [] } ],
1002
            monitoring => $host->{monitoring} || 'pending',
1003
            notes => $host->{notes} || '',
1004
        };
1005
    }
1006
    return {
1007
        version => $registry->{version},
1008
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
1009
        source => $opt{db},
Xdev Host Manager authored a week ago
1010
        hosts => \@hosts,
1011
    };
1012
}
1013

            
Bogdan Timofte authored 4 days ago
1014
sub debug_database_tables_payload {
1015
    my $dbh = dbh();
1016
    my @tables;
1017
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
1018
    $sth->execute;
1019
    while (my ($name) = $sth->fetchrow_array) {
1020
        my $quoted = $dbh->quote_identifier($name);
1021
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1022
        push @tables, {
1023
            name => $name,
1024
            rows => int($count || 0),
1025
        };
1026
    }
1027
    return {
1028
        database => $opt{db},
1029
        generated_at => iso_now(),
1030
        tables => \@tables,
1031
        counts => {
1032
            tables => scalar @tables,
1033
            rows => sum(map { $_->{rows} } @tables),
1034
        },
1035
    };
1036
}
1037

            
1038
sub debug_database_table_payload {
1039
    my ($table, $limit) = @_;
1040
    my $dbh = dbh();
1041
    $table = clean_scalar($table);
1042
    return { error => 'missing_table' } unless length $table;
1043
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1044
    $limit = int($limit || 100);
1045
    $limit = 1 if $limit < 1;
1046
    $limit = 500 if $limit > 500;
1047

            
1048
    my $quoted = $dbh->quote_identifier($table);
1049
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1050
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
1051
    my @index_details;
1052
    for my $index (@$indexes) {
1053
        my $index_name = $index->{name} || '';
1054
        next unless length $index_name;
1055
        my $quoted_index = $dbh->quote_identifier($index_name);
1056
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
1057
        push @index_details, {
1058
            name => $index_name,
1059
            unique => int($index->{unique} || 0),
1060
            origin => $index->{origin} || '',
1061
            partial => int($index->{partial} || 0),
1062
            columns => [ map { $_->{name} || '' } @$index_columns ],
1063
        };
1064
    }
1065
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
1066
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1067
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
1068

            
1069
    return {
1070
        database => $opt{db},
1071
        table => $table,
1072
        generated_at => iso_now(),
1073
        limit => $limit,
1074
        row_count => int($row_count || 0),
1075
        columns => $columns,
1076
        indexes => \@index_details,
1077
        foreign_keys => $foreign_keys,
1078
        rows => $rows,
1079
    };
1080
}
1081

            
Bogdan Timofte authored 4 days ago
1082
sub debug_database_table_export_payload {
1083
    my ($table) = @_;
1084
    my $dbh = dbh();
1085
    $table = clean_scalar($table);
1086
    return { error => 'missing_table' } unless length $table;
1087
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
1088

            
1089
    my $quoted = $dbh->quote_identifier($table);
1090
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
1091
    my @column_names = map { $_->{name} || '' } @$columns;
1092
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
1093
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
1094

            
1095
    return {
1096
        database => $opt{db},
1097
        table => $table,
1098
        generated_at => iso_now(),
1099
        row_count => int($row_count || 0),
1100
        columns => \@column_names,
1101
        rows => $rows,
1102
    };
1103
}
1104

            
1105
sub render_debug_table_csv {
1106
    my ($export) = @_;
1107
    my @columns = @{ $export->{columns} || [] };
1108
    my @lines = (join(',', map { csv_cell($_) } @columns));
1109
    for my $row (@{ $export->{rows} || [] }) {
1110
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
1111
    }
1112
    return join("\n", @lines) . "\n";
1113
}
1114

            
1115
sub csv_cell {
1116
    my ($value) = @_;
1117
    $value = '' unless defined $value;
1118
    $value = "$value";
1119
    $value =~ s/"/""/g;
1120
    return qq("$value") if $value =~ /[",\r\n]/;
1121
    return $value;
1122
}
1123

            
1124
sub debug_table_export_filename {
1125
    my ($table, $extension) = @_;
1126
    $table = clean_scalar($table || 'table');
1127
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
1128
    $table = 'table' unless length $table;
1129
    return "debug-$table.$extension";
1130
}
1131

            
Bogdan Timofte authored 4 days ago
1132
sub debug_table_exists {
1133
    my ($dbh, $table) = @_;
1134
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
1135
    my ($exists) = $dbh->selectrow_array(
1136
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
1137
        undef,
1138
        $table,
1139
    );
1140
    return $exists ? 1 : 0;
1141
}
1142

            
1143
sub sum {
1144
    my $total = 0;
1145
    $total += $_ || 0 for @_;
1146
    return $total;
1147
}
1148

            
Xdev Host Manager authored a week ago
1149
sub ca_script_path {
1150
    return "$project_dir/scripts/ca_manager.sh";
1151
}
1152

            
1153
sub ca_dir {
1154
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
1155
}
1156

            
1157
sub ca_cert_path {
1158
    return ca_dir() . "/certs/ca.cert.pem";
1159
}
1160

            
Bogdan Timofte authored 5 days ago
1161
sub ca_issued_cert_path {
1162
    my ($name) = @_;
1163
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1164
    return ca_dir() . "/issued/$name.cert.pem";
1165
}
1166

            
Bogdan Timofte authored 4 days ago
1167
sub ca_issued_key_path {
1168
    my ($name) = @_;
1169
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
1170
    return ca_dir() . "/issued/$name.key.pem";
1171
}
1172

            
Bogdan Timofte authored 4 days ago
1173
sub ca_private_key_exists {
1174
    my ($name) = @_;
1175
    return 0 unless clean_certificate_id($name || '');
1176
    return -f ca_issued_key_path($name) ? 1 : 0;
1177
}
1178

            
Bogdan Timofte authored 4 days ago
1179
sub ca_manager_output {
1180
    my (@args) = @_;
Xdev Host Manager authored a week ago
1181
    my $script = ca_script_path();
1182
    die "CA manager script is missing\n" unless -x $script;
1183
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
Bogdan Timofte authored 4 days ago
1184
    open my $fh, '-|', $script, @args or die "Cannot run CA manager\n";
Xdev Host Manager authored a week ago
1185
    local $/;
1186
    my $out = <$fh>;
1187
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
1188
    return $out || '';
1189
}
1190

            
1191
sub ca_manager_json {
1192
    my ($command) = @_;
1193
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1194
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1195
    sync_certificates_from_json($out) if $command eq 'list-json';
1196
    return $out;
1197
}
1198

            
1199
sub sync_certificates_from_json {
1200
    my ($json) = @_;
1201
    my $certs = eval { json_decode($json || '[]') };
1202
    return if $@ || ref($certs) ne 'ARRAY';
1203
    my $dbh = dbh();
1204
    my $now = iso_now();
1205
    with_transaction($dbh, sub {
1206
        for my $cert (@$certs) {
1207
            next unless ref($cert) eq 'HASH';
1208
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1209
            next unless $name;
1210
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1211
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1212
            my $cert_path = ca_issued_cert_path($name);
1213
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1214
            my $serial = clean_scalar($cert->{serial} || '');
1215
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1216
            $dbh->do(
1217
                '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) '
1218
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1219
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1220
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1221
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1222
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1223
                undef,
1224
                $name,
1225
                $host_fqdn || undef,
1226
                $dns_names[0] || '',
1227
                clean_scalar($cert->{subject} || ''),
1228
                clean_scalar($cert->{issuer} || ''),
1229
                length($serial) ? $serial : undef,
1230
                clean_scalar($cert->{not_before} || ''),
1231
                clean_scalar($cert->{not_after} || ''),
1232
                length($fingerprint) ? $fingerprint : undef,
1233
                $cert_path,
1234
                $csr_path,
1235
                $now,
1236
                $now,
1237
            );
1238
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1239
            for my $dns_name (@dns_names) {
1240
                next unless length $dns_name;
1241
                $dbh->do(
1242
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1243
                    undef,
1244
                    $name,
1245
                    $dns_name,
1246
                );
1247
            }
1248
        }
1249
    });
1250
}
1251

            
1252
sub infer_certificate_host_fqdn {
1253
    my ($dbh, $dns_names) = @_;
1254
    for my $name (@$dns_names) {
1255
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1256
        return $fqdn if $fqdn;
1257
    }
1258
    for my $name (@$dns_names) {
1259
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1260
        return $fqdn if $fqdn;
1261
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1262
        return $fqdn if $fqdn;
1263
    }
1264
    return '';
Xdev Host Manager authored a week ago
1265
}
1266

            
Xdev Host Manager authored a week ago
1267
sub parse_hosts_yaml {
1268
    my ($text) = @_;
1269
    my %registry = (
1270
        version => 1,
1271
        updated_at => '',
1272
        policy => {},
1273
        hosts => [],
1274
    );
1275
    my ($section, $current, $list_key);
1276
    for my $line (split /\n/, $text) {
1277
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1278
        if ($line =~ /^version:\s*(\d+)/) {
1279
            $registry{version} = int($1);
1280
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1281
            $registry{updated_at} = yaml_unquote($1);
1282
        } elsif ($line =~ /^policy:\s*$/) {
1283
            $section = 'policy';
1284
        } elsif ($line =~ /^hosts:\s*$/) {
1285
            $section = 'hosts';
1286
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1287
            $registry{policy}{$1} = yaml_unquote($2);
1288
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1289
            $current = {
1290
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1291
                fqdn => '',
Xdev Host Manager authored a week ago
1292
                status => 'active',
Bogdan Timofte authored 4 days ago
1293
                ip => '',
1294
                aliases => [],
1295
                vhosts => [],
Xdev Host Manager authored a week ago
1296
                roles => [],
1297
                sources => [],
1298
                monitoring => 'pending',
1299
                notes => '',
1300
            };
1301
            push @{ $registry{hosts} }, $current;
1302
            $list_key = undef;
1303
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1304
            $list_key = $1;
1305
            $current->{$list_key} ||= [];
1306
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1307
            push @{ $current->{$list_key} }, yaml_unquote($1);
1308
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1309
            my $key = $1;
1310
            my $value = yaml_unquote($2);
1311
            if ($key eq 'ip') {
1312
                $current->{ip} = $value;
1313
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1314
                $current->{ip} ||= $value;
1315
            } elsif ($key eq 'fqdn') {
1316
                $current->{fqdn} = normalize_dns_name($value);
1317
            } elsif ($key eq 'names') {
1318
                # ignored here; legacy list is handled after parsing
1319
            } else {
1320
                $current->{$key} = $value;
1321
            }
Xdev Host Manager authored a week ago
1322
            $list_key = undef;
1323
        }
1324
    }
Bogdan Timofte authored 4 days ago
1325
    for my $host (@{ $registry{hosts} }) {
1326
        my @legacy_names = @{ $host->{names} || [] };
1327
        if (@legacy_names) {
1328
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1329
            $host->{fqdn} ||= $legacy->{fqdn};
1330
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1331
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1332
        }
1333
        delete $host->{names};
1334
        $host->{fqdn} ||= canonical_host_fqdn($host);
1335
    }
Xdev Host Manager authored a week ago
1336
    return \%registry;
1337
}
1338

            
1339
sub render_hosts_yaml {
1340
    my ($registry) = @_;
1341
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1342
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1343
    $out .= "policy:\n";
1344
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1345
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1346
    }
1347
    $out .= "hosts:\n";
1348
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1349
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1350
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1351
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1352
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1353
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1354
            $out .= "    $key:\n";
1355
            for my $value (@{ $host->{$key} || [] }) {
1356
                $out .= "      - " . yq($value) . "\n";
1357
            }
1358
        }
1359
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1360
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1361
    }
1362
    return $out;
1363
}
1364

            
Xdev Host Manager authored a week ago
1365
sub parse_work_orders_yaml {
1366
    my ($text) = @_;
1367
    my %orders = (
1368
        version => 1,
1369
        work_orders => [],
1370
    );
Xdev Host Manager authored a week ago
1371
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1372
    for my $line (split /\n/, $text) {
1373
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1374
        if ($line =~ /^version:\s*(\d+)/) {
1375
            $orders{version} = int($1);
1376
        } elsif ($line =~ /^work_orders:\s*$/) {
1377
            $section = 'work_orders';
1378
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1379
            $current = {
1380
                id => yaml_unquote($1),
1381
                status => 'pending',
Xdev Host Manager authored a week ago
1382
                checklist => [],
Xdev Host Manager authored a week ago
1383
                actions => [],
1384
            };
1385
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1386
            $list_section = '';
Xdev Host Manager authored a week ago
1387
            $current_action = undef;
Xdev Host Manager authored a week ago
1388
            $current_item = undef;
1389
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1390
            $list_section = 'checklist';
1391
            $current->{checklist} ||= [];
1392
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1393
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1394
            push @{ $current->{checklist} }, $current_item;
1395
            $current_action = undef;
1396
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1397
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1398
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1399
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1400
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1401
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1402
            $current_action = { type => yaml_unquote($1) };
1403
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1404
            $current_item = undef;
1405
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1406
            $current_action->{$1} = yaml_unquote($2);
1407
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1408
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1409
            $list_section = '';
Xdev Host Manager authored a week ago
1410
            $current_action = undef;
Xdev Host Manager authored a week ago
1411
            $current_item = undef;
Xdev Host Manager authored a week ago
1412
        }
1413
    }
1414
    return \%orders;
1415
}
1416

            
1417
sub render_work_orders_yaml {
1418
    my ($orders) = @_;
1419
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1420
    $out .= "work_orders:\n";
1421
    for my $wo (@{ $orders->{work_orders} || [] }) {
1422
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1423
        for my $key (qw(status title reason created_at confirmed_at result)) {
1424
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1425
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1426
        }
Xdev Host Manager authored a week ago
1427
        $out .= "    checklist:\n";
1428
        for my $item (@{ $wo->{checklist} || [] }) {
1429
            $out .= "      - id: " . yq($item->{id}) . "\n";
1430
            for my $key (qw(text status owner notes updated_at)) {
1431
                next unless exists $item->{$key} && length($item->{$key} || '');
1432
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1433
            }
1434
        }
Xdev Host Manager authored a week ago
1435
        $out .= "    actions:\n";
1436
        for my $action (@{ $wo->{actions} || [] }) {
1437
            $out .= "      - type: " . yq($action->{type}) . "\n";
1438
            for my $key (qw(host_id name)) {
1439
                next unless exists $action->{$key} && length($action->{$key} || '');
1440
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1441
            }
1442
        }
1443
    }
1444
    return $out;
1445
}
1446

            
Xdev Host Manager authored a week ago
1447
sub request_payload {
1448
    my ($headers, $body) = @_;
1449
    my $type = $headers->{'content-type'} || '';
1450
    if ($type =~ m{application/json}) {
1451
        return json_decode($body || '{}');
1452
    }
1453
    return { parse_params($body || '') };
1454
}
1455

            
1456
sub json_bool {
1457
    my ($value) = @_;
1458
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1459
}
1460

            
1461
sub json_encode {
1462
    my ($value) = @_;
1463
    if (!defined $value) {
1464
        return 'null';
1465
    }
1466
    my $ref = ref($value);
1467
    if (!$ref) {
1468
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1469
        return json_string($value);
1470
    }
1471
    if ($ref eq 'HostManager::JSONBool') {
1472
        return $$value ? 'true' : 'false';
1473
    }
1474
    if ($ref eq 'ARRAY') {
1475
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1476
    }
1477
    if ($ref eq 'HASH') {
1478
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1479
    }
1480
    return json_string("$value");
1481
}
1482

            
1483
sub json_string {
1484
    my ($value) = @_;
1485
    $value = '' unless defined $value;
1486
    $value =~ s/\\/\\\\/g;
1487
    $value =~ s/"/\\"/g;
1488
    $value =~ s/\n/\\n/g;
1489
    $value =~ s/\r/\\r/g;
1490
    $value =~ s/\t/\\t/g;
1491
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1492
    return qq("$value");
1493
}
1494

            
1495
sub json_decode {
1496
    my ($text) = @_;
1497
    my $i = 0;
1498
    my $len = length($text);
1499
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1500

            
1501
    $skip_ws = sub {
1502
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1503
    };
1504

            
1505
    $parse_string = sub {
1506
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1507
        $i++;
1508
        my $out = '';
1509
        while ($i < $len) {
1510
            my $ch = substr($text, $i++, 1);
1511
            return $out if $ch eq '"';
1512
            if ($ch eq "\\") {
1513
                die "Bad JSON escape\n" if $i >= $len;
1514
                my $esc = substr($text, $i++, 1);
1515
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1516
                    $out .= $esc;
1517
                } elsif ($esc eq 'b') {
1518
                    $out .= "\b";
1519
                } elsif ($esc eq 'f') {
1520
                    $out .= "\f";
1521
                } elsif ($esc eq 'n') {
1522
                    $out .= "\n";
1523
                } elsif ($esc eq 'r') {
1524
                    $out .= "\r";
1525
                } elsif ($esc eq 't') {
1526
                    $out .= "\t";
1527
                } elsif ($esc eq 'u') {
1528
                    my $hex = substr($text, $i, 4);
1529
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1530
                    $out .= chr(hex($hex));
1531
                    $i += 4;
1532
                } else {
1533
                    die "Bad JSON escape\n";
1534
                }
1535
            } else {
1536
                $out .= $ch;
1537
            }
1538
        }
1539
        die "Unterminated JSON string\n";
1540
    };
1541

            
1542
    $parse_number = sub {
1543
        my $start = $i;
1544
        $i++ if substr($text, $i, 1) eq '-';
1545
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1546
        if ($i < $len && substr($text, $i, 1) eq '.') {
1547
            $i++;
1548
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1549
        }
1550
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1551
            $i++;
1552
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1553
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1554
        }
1555
        return 0 + substr($text, $start, $i - $start);
1556
    };
1557

            
1558
    $parse_array = sub {
1559
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1560
        $i++;
1561
        my @out;
1562
        $skip_ws->();
1563
        if ($i < $len && substr($text, $i, 1) eq ']') {
1564
            $i++;
1565
            return \@out;
1566
        }
1567
        while (1) {
1568
            push @out, $parse_value->();
1569
            $skip_ws->();
1570
            my $ch = substr($text, $i++, 1);
1571
            last if $ch eq ']';
1572
            die "Expected JSON array comma\n" unless $ch eq ',';
1573
        }
1574
        return \@out;
1575
    };
1576

            
1577
    $parse_object = sub {
1578
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1579
        $i++;
1580
        my %out;
1581
        $skip_ws->();
1582
        if ($i < $len && substr($text, $i, 1) eq '}') {
1583
            $i++;
1584
            return \%out;
1585
        }
1586
        while (1) {
1587
            $skip_ws->();
1588
            my $key = $parse_string->();
1589
            $skip_ws->();
1590
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1591
            $out{$key} = $parse_value->();
1592
            $skip_ws->();
1593
            my $ch = substr($text, $i++, 1);
1594
            last if $ch eq '}';
1595
            die "Expected JSON object comma\n" unless $ch eq ',';
1596
        }
1597
        return \%out;
1598
    };
1599

            
1600
    $parse_value = sub {
1601
        $skip_ws->();
1602
        die "Unexpected end of JSON\n" if $i >= $len;
1603
        my $ch = substr($text, $i, 1);
1604
        return $parse_string->() if $ch eq '"';
1605
        return $parse_object->() if $ch eq '{';
1606
        return $parse_array->() if $ch eq '[';
1607
        if (substr($text, $i, 4) eq 'true') {
1608
            $i += 4;
1609
            return json_bool(1);
1610
        }
1611
        if (substr($text, $i, 5) eq 'false') {
1612
            $i += 5;
1613
            return json_bool(0);
1614
        }
1615
        if (substr($text, $i, 4) eq 'null') {
1616
            $i += 4;
1617
            return undef;
1618
        }
1619
        return $parse_number->() if $ch =~ /[-0-9]/;
1620
        die "Unexpected JSON token\n";
1621
    };
1622

            
1623
    my $value = $parse_value->();
1624
    $skip_ws->();
1625
    die "Trailing JSON content\n" if $i != $len;
1626
    return $value;
1627
}
1628

            
1629
sub parse_params {
1630
    my ($text) = @_;
1631
    my %out;
1632
    for my $pair (split /&/, $text) {
1633
        next unless length $pair;
1634
        my ($k, $v) = split /=/, $pair, 2;
1635
        $out{url_decode($k)} = url_decode($v || '');
1636
    }
1637
    return %out;
1638
}
1639

            
1640
sub clean_id {
1641
    my ($value) = @_;
1642
    $value = lc clean_scalar($value);
1643
    $value =~ s/[^a-z0-9_.-]+/-/g;
1644
    $value =~ s/^-+|-+$//g;
1645
    return $value;
1646
}
1647

            
Bogdan Timofte authored 4 days ago
1648
sub clean_certificate_id {
1649
    my ($value) = @_;
1650
    $value = clean_scalar($value);
1651
    return '' unless length $value;
1652
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1653
}
1654

            
Xdev Host Manager authored a week ago
1655
sub clean_scalar {
1656
    my ($value) = @_;
1657
    $value = '' unless defined $value;
1658
    $value =~ s/[\r\n\t]+/ /g;
1659
    $value =~ s/^\s+|\s+$//g;
1660
    return $value;
1661
}
1662

            
1663
sub clean_list {
1664
    my ($value) = @_;
1665
    return () unless defined $value;
1666
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1667
    my @clean;
1668
    for my $item (@items) {
1669
        $item = clean_scalar($item);
1670
        push @clean, $item if length $item;
1671
    }
1672
    return @clean;
1673
}
1674

            
1675
sub yq {
1676
    my ($value) = @_;
1677
    $value = '' unless defined $value;
1678
    $value =~ s/\\/\\\\/g;
1679
    $value =~ s/"/\\"/g;
1680
    return qq("$value");
1681
}
1682

            
1683
sub yaml_unquote {
1684
    my ($value) = @_;
1685
    $value = '' unless defined $value;
1686
    $value =~ s/^\s+|\s+$//g;
1687
    if ($value =~ /^"(.*)"$/) {
1688
        $value = $1;
1689
        $value =~ s/\\"/"/g;
1690
        $value =~ s/\\\\/\\/g;
1691
    }
1692
    return $value;
1693
}
1694

            
1695
sub verify_totp {
1696
    my ($secret, $otp) = @_;
1697
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1698
    my $key = eval { base32_decode($secret) };
1699
    return 0 if $@ || !length $key;
1700
    my $counter = int(time() / 30);
1701
    for my $offset (-1, 0, 1) {
1702
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1703
    }
1704
    return 0;
1705
}
1706

            
1707
sub totp_code {
1708
    my ($key, $counter) = @_;
1709
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1710
    my $hash = hmac_sha1($msg, $key);
1711
    my $offset = ord(substr($hash, -1)) & 0x0f;
1712
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1713
    return sprintf('%06d', $bin % 1_000_000);
1714
}
1715

            
1716
sub base32_decode {
1717
    my ($text) = @_;
1718
    $text = uc($text || '');
1719
    $text =~ s/[^A-Z2-7]//g;
1720
    my %map;
1721
    my @chars = ('A'..'Z', '2'..'7');
1722
    @map{@chars} = (0..31);
1723
    my ($bits, $value, $out) = (0, 0, '');
1724
    for my $char (split //, $text) {
1725
        die "Invalid base32\n" unless exists $map{$char};
1726
        $value = ($value << 5) | $map{$char};
1727
        $bits += 5;
1728
        while ($bits >= 8) {
1729
            $bits -= 8;
1730
            $out .= chr(($value >> $bits) & 0xff);
1731
        }
1732
    }
1733
    return $out;
1734
}
1735

            
1736
sub create_session {
1737
    my $nonce = random_hex(24);
1738
    my $expires = int(time() + 8 * 3600);
1739
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1740
    my $token = "$nonce:$expires:$sig";
1741
    $sessions{$token} = $expires;
1742
    return $token;
1743
}
1744

            
1745
sub is_authenticated {
1746
    my ($headers) = @_;
1747
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1748
    return 0 unless $token;
1749
    my ($nonce, $expires, $sig) = split /:/, $token;
1750
    return 0 unless $nonce && $expires && $sig;
1751
    return 0 if $expires < time();
1752
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1753
    return exists $sessions{$token};
1754
}
1755

            
1756
sub expire_session {
1757
    my ($headers) = @_;
1758
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1759
    delete $sessions{$token} if $token;
1760
}
1761

            
1762
sub cookie_value {
1763
    my ($cookie, $name) = @_;
1764
    for my $part (split /;\s*/, $cookie) {
1765
        my ($k, $v) = split /=/, $part, 2;
1766
        return $v if defined $k && $k eq $name;
1767
    }
1768
    return '';
1769
}
1770

            
1771
sub send_json {
1772
    my ($client, $status, $payload, $extra_headers) = @_;
1773
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1774
}
1775

            
Xdev Host Manager authored a week ago
1776
sub send_json_raw {
1777
    my ($client, $status, $json_body, $extra_headers) = @_;
1778
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1779
}
1780

            
Xdev Host Manager authored a week ago
1781
sub send_html {
1782
    my ($client, $status, $html) = @_;
1783
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1784
}
1785

            
1786
sub send_text {
1787
    my ($client, $status, $text) = @_;
1788
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1789
}
1790

            
1791
sub send_download {
1792
    my ($client, $status, $content, $type, $filename) = @_;
1793
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1794
}
1795

            
1796
sub send_file {
1797
    my ($client, $path, $type, $filename) = @_;
1798
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1799
    return send_download($client, 200, read_file($path), $type, $filename);
1800
}
1801

            
1802
sub send_response {
1803
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1804
    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
1805
    $body = '' unless defined $body;
1806
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1807
    print $client "Content-Type: $type\r\n";
1808
    print $client "Content-Length: " . length($body) . "\r\n";
1809
    print $client "Cache-Control: no-store\r\n";
1810
    print $client "$_\r\n" for @{ $extra_headers || [] };
1811
    print $client "Connection: close\r\n\r\n";
1812
    print $client $body;
1813
}
1814

            
1815
sub read_file {
1816
    my ($path) = @_;
1817
    open my $fh, '<', $path or die "Cannot read $path: $!";
1818
    local $/;
1819
    return <$fh>;
1820
}
1821

            
1822
sub write_file {
1823
    my ($path, $content) = @_;
1824
    open my $fh, '>', $path or die "Cannot write $path: $!";
1825
    print {$fh} $content;
1826
    close $fh or die "Cannot close $path: $!";
1827
}
1828

            
1829
sub backup_file {
1830
    my ($path) = @_;
1831
    return unless -f $path;
1832
    my $backup_dir = "$project_dir/backups/host-manager";
1833
    make_path($backup_dir) unless -d $backup_dir;
1834
    my $name = $path;
1835
    $name =~ s{.*/}{};
1836
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1837
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1838
}
1839

            
Bogdan Timofte authored 4 days ago
1840
my $db_handle;
Bogdan Timofte authored 4 days ago
1841
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1842

            
1843
sub dbh {
1844
    return $db_handle if $db_handle;
1845
    ensure_parent_dir($opt{db});
1846
    $db_handle = DBI->connect(
1847
        "dbi:SQLite:dbname=$opt{db}",
1848
        '',
1849
        '',
1850
        {
1851
            RaiseError => 1,
1852
            PrintError => 0,
1853
            AutoCommit => 1,
1854
            sqlite_unicode => 1,
1855
        },
1856
    ) or die "Cannot open SQLite database $opt{db}\n";
1857
    $db_handle->do('PRAGMA journal_mode = WAL');
1858
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1859
    create_database_schema($db_handle);
1860
    seed_database($db_handle) unless $db_seeded++;
1861
    return $db_handle;
1862
}
1863

            
1864
sub create_database_schema {
1865
    my ($dbh) = @_;
1866
    $dbh->do(<<'SQL');
1867
CREATE TABLE IF NOT EXISTS schema_meta (
1868
    key TEXT PRIMARY KEY,
1869
    value TEXT NOT NULL,
1870
    updated_at TEXT NOT NULL
1871
)
1872
SQL
1873
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1874
CREATE TABLE IF NOT EXISTS documents (
1875
    name TEXT PRIMARY KEY,
1876
    content TEXT NOT NULL,
1877
    updated_at TEXT NOT NULL
1878
)
1879
SQL
Bogdan Timofte authored 4 days ago
1880
    $dbh->do(
1881
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1882
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1883
        undef, 'schema_version', '2', iso_now()
1884
    );
1885
    $dbh->do(<<'SQL');
1886
CREATE TABLE IF NOT EXISTS hosts (
1887
    fqdn TEXT PRIMARY KEY,
1888
    legacy_id TEXT NOT NULL UNIQUE,
1889
    status TEXT NOT NULL DEFAULT 'active',
1890
    hosts_ip TEXT NOT NULL DEFAULT '',
1891
    dns_ip TEXT NOT NULL DEFAULT '',
1892
    monitoring TEXT NOT NULL DEFAULT 'pending',
1893
    notes TEXT NOT NULL DEFAULT '',
1894
    created_at TEXT NOT NULL,
1895
    updated_at TEXT NOT NULL
1896
)
1897
SQL
1898
    $dbh->do(<<'SQL');
1899
CREATE TABLE IF NOT EXISTS host_aliases (
1900
    alias_name TEXT NOT NULL,
1901
    host_fqdn TEXT NOT NULL,
1902
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1903
    status TEXT NOT NULL DEFAULT 'active',
1904
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1905
    created_at TEXT NOT NULL,
1906
    retired_at TEXT,
1907
    notes TEXT NOT NULL DEFAULT '',
1908
    PRIMARY KEY (alias_name, host_fqdn),
1909
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1910
)
1911
SQL
1912
    $dbh->do(<<'SQL');
1913
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1914
ON host_aliases(alias_name)
1915
WHERE status = 'active'
1916
SQL
1917
    $dbh->do(<<'SQL');
1918
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1919
ON host_aliases(host_fqdn, status)
1920
SQL
1921
    $dbh->do(<<'SQL');
1922
CREATE TABLE IF NOT EXISTS host_roles (
1923
    host_fqdn TEXT NOT NULL,
1924
    role TEXT NOT NULL,
1925
    status TEXT NOT NULL DEFAULT 'active',
1926
    created_at TEXT NOT NULL,
1927
    retired_at TEXT,
1928
    PRIMARY KEY (host_fqdn, role),
1929
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1930
)
1931
SQL
1932
    $dbh->do(<<'SQL');
1933
CREATE TABLE IF NOT EXISTS host_sources (
1934
    host_fqdn TEXT NOT NULL,
1935
    source TEXT NOT NULL,
1936
    status TEXT NOT NULL DEFAULT 'active',
1937
    created_at TEXT NOT NULL,
1938
    retired_at TEXT,
1939
    PRIMARY KEY (host_fqdn, source),
1940
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1941
)
1942
SQL
1943
    $dbh->do(<<'SQL');
1944
CREATE TABLE IF NOT EXISTS host_flags (
1945
    host_fqdn TEXT NOT NULL,
1946
    flag TEXT NOT NULL,
1947
    value TEXT NOT NULL DEFAULT '1',
1948
    created_at TEXT NOT NULL,
1949
    updated_at TEXT NOT NULL,
1950
    PRIMARY KEY (host_fqdn, flag),
1951
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1952
)
1953
SQL
1954
    $dbh->do(<<'SQL');
1955
CREATE TABLE IF NOT EXISTS host_ssh (
1956
    host_fqdn TEXT NOT NULL,
1957
    profile_name TEXT NOT NULL DEFAULT 'default',
1958
    username TEXT NOT NULL DEFAULT '',
1959
    port INTEGER NOT NULL DEFAULT 22,
1960
    identity_file TEXT NOT NULL DEFAULT '',
1961
    address TEXT NOT NULL DEFAULT '',
1962
    local_forward_host TEXT NOT NULL DEFAULT '',
1963
    local_forward_port INTEGER,
1964
    remote_forward_host TEXT NOT NULL DEFAULT '',
1965
    remote_forward_port INTEGER,
1966
    notes TEXT NOT NULL DEFAULT '',
1967
    created_at TEXT NOT NULL,
1968
    updated_at TEXT NOT NULL,
1969
    PRIMARY KEY (host_fqdn, profile_name),
1970
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1971
)
1972
SQL
1973
    $dbh->do(<<'SQL');
1974
CREATE TABLE IF NOT EXISTS certificates (
1975
    certificate_id TEXT PRIMARY KEY,
1976
    host_fqdn TEXT,
1977
    common_name TEXT NOT NULL DEFAULT '',
1978
    subject TEXT NOT NULL DEFAULT '',
1979
    issuer TEXT NOT NULL DEFAULT '',
1980
    serial TEXT UNIQUE,
1981
    status TEXT NOT NULL DEFAULT 'issued',
1982
    not_before TEXT NOT NULL DEFAULT '',
1983
    not_after TEXT NOT NULL DEFAULT '',
1984
    fingerprint_sha256 TEXT UNIQUE,
1985
    cert_path TEXT NOT NULL DEFAULT '',
1986
    csr_path TEXT NOT NULL DEFAULT '',
1987
    created_at TEXT NOT NULL,
1988
    updated_at TEXT NOT NULL,
1989
    notes TEXT NOT NULL DEFAULT '',
1990
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1991
)
1992
SQL
1993
    $dbh->do(<<'SQL');
1994
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1995
    certificate_id TEXT NOT NULL,
1996
    dns_name TEXT NOT NULL,
1997
    PRIMARY KEY (certificate_id, dns_name),
1998
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1999
)
2000
SQL
2001
    $dbh->do(<<'SQL');
2002
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
2003
ON certificate_dns_names(dns_name)
2004
SQL
2005
    $dbh->do(<<'SQL');
2006
CREATE TABLE IF NOT EXISTS vhosts (
2007
    vhost_fqdn TEXT PRIMARY KEY,
2008
    host_fqdn TEXT NOT NULL,
2009
    status TEXT NOT NULL DEFAULT 'active',
2010
    service_name TEXT NOT NULL DEFAULT '',
2011
    upstream_url TEXT NOT NULL DEFAULT '',
2012
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
2013
    certificate_id TEXT,
2014
    notes TEXT NOT NULL DEFAULT '',
2015
    created_at TEXT NOT NULL,
2016
    updated_at TEXT NOT NULL,
2017
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
2018
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
2019
)
2020
SQL
2021
    $dbh->do(<<'SQL');
2022
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
2023
ON vhosts(host_fqdn, status)
2024
SQL
2025
    $dbh->do(<<'SQL');
2026
CREATE TABLE IF NOT EXISTS data_workers (
2027
    worker_id TEXT PRIMARY KEY,
2028
    worker_type TEXT NOT NULL,
2029
    name TEXT NOT NULL DEFAULT '',
2030
    status TEXT NOT NULL DEFAULT 'active',
2031
    source TEXT NOT NULL DEFAULT '',
2032
    last_run_at TEXT,
2033
    notes TEXT NOT NULL DEFAULT '',
2034
    created_at TEXT NOT NULL,
2035
    updated_at TEXT NOT NULL
2036
)
2037
SQL
2038
    $dbh->do(<<'SQL');
2039
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
2040
ON data_workers(worker_type, status)
2041
SQL
2042
    $dbh->do(<<'SQL');
2043
CREATE TABLE IF NOT EXISTS dhcp_leases (
2044
    lease_key TEXT PRIMARY KEY,
2045
    worker_id TEXT NOT NULL,
2046
    host_fqdn TEXT,
2047
    observed_name TEXT NOT NULL DEFAULT '',
2048
    ip_address TEXT NOT NULL,
2049
    mac_address TEXT NOT NULL DEFAULT '',
2050
    lease_state TEXT NOT NULL DEFAULT '',
2051
    first_seen TEXT NOT NULL,
2052
    last_seen TEXT NOT NULL,
2053
    raw TEXT NOT NULL DEFAULT '',
2054
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2055
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2056
)
2057
SQL
2058
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
2059
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
2060
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
2061
    $dbh->do(<<'SQL');
2062
CREATE TABLE IF NOT EXISTS mdns_observations (
2063
    observation_key TEXT PRIMARY KEY,
2064
    worker_id TEXT NOT NULL,
2065
    host_fqdn TEXT,
2066
    observed_name TEXT NOT NULL,
2067
    ip_address TEXT NOT NULL,
2068
    rr_type TEXT NOT NULL DEFAULT 'A',
2069
    ttl INTEGER NOT NULL DEFAULT 0,
2070
    first_seen TEXT NOT NULL,
2071
    last_seen TEXT NOT NULL,
2072
    seen_count INTEGER NOT NULL DEFAULT 1,
2073
    last_peer TEXT NOT NULL DEFAULT '',
2074
    raw TEXT NOT NULL DEFAULT '',
2075
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
2076
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2077
)
2078
SQL
2079
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
2080
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
2081
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
2082
    $dbh->do(<<'SQL');
2083
CREATE TABLE IF NOT EXISTS work_orders (
2084
    id TEXT PRIMARY KEY,
2085
    status TEXT NOT NULL DEFAULT 'pending',
2086
    title TEXT NOT NULL DEFAULT '',
2087
    reason TEXT NOT NULL DEFAULT '',
2088
    created_at TEXT NOT NULL,
2089
    confirmed_at TEXT NOT NULL DEFAULT '',
2090
    result TEXT NOT NULL DEFAULT '',
2091
    updated_at TEXT NOT NULL
2092
)
2093
SQL
2094
    $dbh->do(<<'SQL');
2095
CREATE TABLE IF NOT EXISTS work_order_checklist (
2096
    work_order_id TEXT NOT NULL,
2097
    item_id TEXT NOT NULL,
2098
    text TEXT NOT NULL DEFAULT '',
2099
    status TEXT NOT NULL DEFAULT 'pending',
2100
    owner TEXT NOT NULL DEFAULT '',
2101
    notes TEXT NOT NULL DEFAULT '',
2102
    updated_at TEXT NOT NULL DEFAULT '',
2103
    PRIMARY KEY (work_order_id, item_id),
2104
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
2105
)
2106
SQL
2107
    $dbh->do(<<'SQL');
2108
CREATE TABLE IF NOT EXISTS work_order_actions (
2109
    work_order_id TEXT NOT NULL,
2110
    position INTEGER NOT NULL,
2111
    type TEXT NOT NULL,
2112
    host_fqdn TEXT,
2113
    host_legacy_id TEXT NOT NULL DEFAULT '',
2114
    name TEXT NOT NULL DEFAULT '',
2115
    payload TEXT NOT NULL DEFAULT '',
2116
    PRIMARY KEY (work_order_id, position),
2117
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
2118
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
2119
)
2120
SQL
Bogdan Timofte authored 4 days ago
2121
}
2122

            
Bogdan Timofte authored 4 days ago
2123
sub seed_database {
2124
    my ($dbh) = @_;
2125
    seed_default_workers($dbh);
2126

            
2127
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2128
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2129
        normalize_registry_policy($registry);
2130
        with_transaction($dbh, sub {
2131
            import_registry_to_db($dbh, $registry, 0);
2132
        });
2133
    }
2134

            
2135
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2136
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2137
        with_transaction($dbh, sub {
2138
            import_work_orders_to_db($dbh, $orders);
2139
        });
2140
    }
2141

            
2142
    seed_mdns_observations_from_yaml($dbh);
2143
}
2144

            
2145
sub with_transaction {
2146
    my ($dbh, $code) = @_;
2147
    return $code->() unless $dbh->{AutoCommit};
2148
    $dbh->begin_work;
2149
    my $ok = eval {
2150
        $code->();
2151
        1;
2152
    };
2153
    if (!$ok) {
2154
        my $err = $@ || 'transaction failed';
2155
        eval { $dbh->rollback };
2156
        die $err;
2157
    }
2158
    $dbh->commit;
2159
}
2160

            
2161
sub db_scalar {
2162
    my ($dbh, $sql, @bind) = @_;
2163
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2164
    return $value || 0;
2165
}
2166

            
2167
sub legacy_document_text {
2168
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2169
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2170
    return $row->{content} if $row && defined $row->{content};
2171
    return read_file($seed_path) if -f $seed_path;
2172
    return $default_text;
2173
}
2174

            
2175
sub load_registry_from_db {
2176
    my $dbh = dbh();
2177
    my $registry = {
2178
        version => 1,
2179
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2180
        policy => {},
2181
        hosts => [],
2182
    };
Bogdan Timofte authored 4 days ago
2183

            
Bogdan Timofte authored 4 days ago
2184
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
2185
    $sth->execute;
2186
    while (my $row = $sth->fetchrow_hashref) {
2187
        my $fqdn = $row->{fqdn};
2188
        push @{ $registry->{hosts} }, {
2189
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
2190
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
2191
            status => $row->{status},
Bogdan Timofte authored 4 days ago
2192
            ip => canonical_ip($row),
2193
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
2194
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
2195
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
2196
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
2197
            monitoring => $row->{monitoring},
2198
            notes => $row->{notes},
2199
        };
2200
    }
2201

            
2202
    return $registry;
Bogdan Timofte authored 4 days ago
2203
}
2204

            
Bogdan Timofte authored 4 days ago
2205
sub save_registry_to_db {
2206
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2207
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2208
    with_transaction($dbh, sub {
2209
        import_registry_to_db($dbh, $registry, 1);
2210
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2211
    });
2212
}
2213

            
2214
sub import_registry_to_db {
2215
    my ($dbh, $registry, $retire_missing) = @_;
2216
    my %seen;
2217
    for my $host (@{ $registry->{hosts} || [] }) {
2218
        my $fqdn = upsert_host_to_db($dbh, $host);
2219
        $seen{$fqdn} = 1 if $fqdn;
2220
    }
2221

            
2222
    return unless $retire_missing;
2223
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2224
    $sth->execute('retired');
2225
    while (my ($fqdn) = $sth->fetchrow_array) {
2226
        next if $seen{$fqdn};
2227
        retire_host_in_db($dbh, $fqdn);
2228
    }
2229
}
2230

            
2231
sub upsert_host_to_db {
2232
    my ($dbh, $host) = @_;
2233
    my $now = iso_now();
2234
    my $fqdn = canonical_host_fqdn($host);
2235
    return '' unless $fqdn;
2236
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2237
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2238
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2239
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2240
    my $notes = clean_scalar($host->{notes} || '');
2241

            
Bogdan Timofte authored 4 days ago
2242
    $dbh->do(
Bogdan Timofte authored 4 days ago
2243
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2244
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2245
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2246
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2247
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2248
        undef,
Bogdan Timofte authored 4 days ago
2249
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2250
    );
2251

            
2252
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2253
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2254
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2255
    return $fqdn;
2256
}
2257

            
2258
sub sync_host_values {
2259
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2260
    my $now = iso_now();
2261
    my %active = map { $_ => 1 } @$values;
2262
    for my $value (@$values) {
2263
        $dbh->do(
2264
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2265
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2266
            undef,
2267
            $fqdn, $value, $now,
2268
        );
2269
    }
2270

            
2271
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2272
    $sth->execute($fqdn);
2273
    while (my ($value) = $sth->fetchrow_array) {
2274
        next if $active{$value};
2275
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2276
    }
2277
}
2278

            
Bogdan Timofte authored 4 days ago
2279
sub sync_host_aliases_and_vhosts {
2280
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2281
    my $now = iso_now();
2282
    my (%aliases, %vhosts);
2283
    if (my $short = short_alias_for_fqdn($fqdn)) {
2284
        $aliases{$short} = 1;
2285
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2286
    }
Bogdan Timofte authored 4 days ago
2287
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2288
        $name = normalize_dns_name($name);
2289
        next unless length $name;
2290
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2291
        $aliases{$name} = 1;
2292
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2293
        if (my $short = short_alias_for_fqdn($name)) {
2294
            $aliases{$short} = 1;
2295
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2296
        }
2297
    }
2298
    for my $name (@$vhosts_in) {
2299
        $name = normalize_dns_name($name);
2300
        next unless length $name;
2301
        $vhosts{$name} = 1;
2302
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2303
        if (my $short = short_alias_for_fqdn($name)) {
2304
            $aliases{$short} = 1;
2305
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2306
        }
2307
    }
2308

            
2309
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2310
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2311
}
2312

            
2313
sub upsert_alias_to_db {
2314
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2315
    my ($existing_fqdn) = $dbh->selectrow_array(
2316
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2317
        undef,
2318
        $alias,
2319
    );
2320
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2321
        if ($kind eq 'derived-vhost') {
2322
            $dbh->do(
2323
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2324
                undef,
2325
                $now, $alias, $existing_fqdn,
2326
            );
2327
        } else {
2328
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2329
        }
2330
    }
Bogdan Timofte authored 4 days ago
2331
    $dbh->do(
2332
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2333
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2334
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2335
        undef,
2336
        $alias, $fqdn, $kind, $now,
2337
    );
2338
}
2339

            
2340
sub upsert_vhost_to_db {
2341
    my ($dbh, $fqdn, $vhost, $now) = @_;
2342
    my $service = vhost_service_name($vhost);
2343
    $dbh->do(
2344
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2345
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2346
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2347
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2348
        undef,
2349
        $vhost, $fqdn, $service, $now, $now,
2350
    );
2351
}
2352

            
2353
sub retire_missing_names {
2354
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2355
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2356
    $sth->execute($fqdn);
2357
    while (my ($name) = $sth->fetchrow_array) {
2358
        next if $active->{$name};
2359
        if ($table eq 'host_aliases') {
2360
            $dbh->do(
2361
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2362
                undef, $now, $fqdn, $name,
2363
            );
2364
        } else {
2365
            $dbh->do(
2366
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2367
                undef, $now, $fqdn, $name,
2368
            );
2369
        }
2370
    }
2371
}
2372

            
2373
sub retire_host_in_db {
2374
    my ($dbh, $fqdn) = @_;
2375
    my $now = iso_now();
2376
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2377
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2378
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2379
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2380
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2381
}
2382

            
Bogdan Timofte authored 4 days ago
2383
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2384
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2385
    my @names;
Bogdan Timofte authored 4 days ago
2386
    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");
2387
    $aliases->execute($fqdn);
2388
    while (my ($name) = $aliases->fetchrow_array) {
2389
        push @names, $name;
2390
    }
Bogdan Timofte authored 4 days ago
2391
    return unique_preserve(@names);
2392
}
2393

            
2394
sub active_vhosts_for_host {
2395
    my ($dbh, $fqdn) = @_;
2396
    my @names;
Bogdan Timofte authored 4 days ago
2397
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2398
    $vhosts->execute($fqdn);
2399
    while (my ($name) = $vhosts->fetchrow_array) {
2400
        push @names, $name;
2401
    }
2402
    return unique_preserve(@names);
2403
}
2404

            
2405
sub active_values_for_host {
2406
    my ($dbh, $table, $column, $fqdn) = @_;
2407
    my @values;
2408
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2409
    $sth->execute($fqdn);
2410
    while (my ($value) = $sth->fetchrow_array) {
2411
        push @values, $value;
2412
    }
2413
    return @values;
2414
}
2415

            
2416
sub load_work_orders_from_db {
2417
    my $dbh = dbh();
2418
    my $orders = { version => 1, work_orders => [] };
2419
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2420
    $sth->execute;
2421
    while (my $row = $sth->fetchrow_hashref) {
2422
        my $wo = {
2423
            id => $row->{id},
2424
            status => $row->{status},
2425
            title => $row->{title},
2426
            reason => $row->{reason},
2427
            created_at => $row->{created_at},
2428
            checklist => [],
2429
            actions => [],
2430
        };
2431
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2432
        $wo->{result} = $row->{result} if length($row->{result} || '');
2433

            
2434
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2435
        $items->execute($row->{id});
2436
        while (my $item = $items->fetchrow_hashref) {
2437
            my %copy = (
2438
                id => $item->{item_id},
2439
                text => $item->{text},
2440
                status => $item->{status},
2441
            );
2442
            for my $key (qw(owner notes updated_at)) {
2443
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2444
            }
2445
            push @{ $wo->{checklist} }, \%copy;
2446
        }
2447

            
2448
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2449
        $actions->execute($row->{id});
2450
        while (my $action = $actions->fetchrow_hashref) {
2451
            my %copy = ( type => $action->{type} );
2452
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2453
            $copy{name} = $action->{name} if length($action->{name} || '');
2454
            push @{ $wo->{actions} }, \%copy;
2455
        }
2456

            
2457
        push @{ $orders->{work_orders} }, $wo;
2458
    }
2459
    return $orders;
2460
}
2461

            
2462
sub save_work_orders_to_db {
2463
    my ($orders) = @_;
2464
    my $dbh = dbh();
2465
    with_transaction($dbh, sub {
2466
        import_work_orders_to_db($dbh, $orders);
2467
    });
2468
}
2469

            
2470
sub import_work_orders_to_db {
2471
    my ($dbh, $orders) = @_;
2472
    my $now = iso_now();
2473
    my %seen;
2474
    for my $wo (@{ $orders->{work_orders} || [] }) {
2475
        my $id = clean_scalar($wo->{id} || '');
2476
        next unless $id;
2477
        $seen{$id} = 1;
2478
        $dbh->do(
2479
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2480
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2481
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2482
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2483
            undef,
2484
            $id,
2485
            clean_scalar($wo->{status} || 'pending'),
2486
            clean_scalar($wo->{title} || ''),
2487
            clean_scalar($wo->{reason} || ''),
2488
            clean_scalar($wo->{created_at} || $now),
2489
            clean_scalar($wo->{confirmed_at} || ''),
2490
            clean_scalar($wo->{result} || ''),
2491
            $now,
2492
        );
2493
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2494
        for my $item (@{ $wo->{checklist} || [] }) {
2495
            $dbh->do(
2496
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2497
                undef,
2498
                $id,
2499
                clean_scalar($item->{id} || ''),
2500
                clean_scalar($item->{text} || ''),
2501
                clean_scalar($item->{status} || 'pending'),
2502
                clean_scalar($item->{owner} || ''),
2503
                clean_scalar($item->{notes} || ''),
2504
                clean_scalar($item->{updated_at} || ''),
2505
            );
2506
        }
2507
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2508
        my $position = 0;
2509
        for my $action (@{ $wo->{actions} || [] }) {
2510
            my $legacy_id = clean_id($action->{host_id} || '');
2511
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2512
            $dbh->do(
2513
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2514
                undef,
2515
                $id,
2516
                $position++,
2517
                clean_scalar($action->{type} || ''),
2518
                $host_fqdn || undef,
2519
                $legacy_id,
2520
                normalize_dns_name($action->{name} || ''),
2521
                '',
2522
            );
2523
        }
2524
    }
2525
}
2526

            
2527
sub seed_default_workers {
2528
    my ($dbh) = @_;
2529
    my $now = iso_now();
2530
    my @workers = (
2531
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2532
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2533
    );
2534
    for my $worker (@workers) {
2535
        $dbh->do(
2536
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2537
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2538
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2539
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2540
            undef,
2541
            @$worker,
2542
            $now,
2543
            $now,
2544
        );
2545
    }
2546
}
2547

            
2548
sub seed_mdns_observations_from_yaml {
2549
    my ($dbh) = @_;
2550
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2551
    my $path = "$project_dir/var/mdns-observations.yaml";
2552
    return unless -f $path;
2553
    my $db = parse_mdns_observations_yaml(read_file($path));
2554
    with_transaction($dbh, sub {
2555
        for my $observation (@{ $db->{observations} || [] }) {
2556
            $dbh->do(
2557
                '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) '
2558
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2559
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2560
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2561
                undef,
2562
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2563
                clean_scalar($observation->{name} || ''),
2564
                clean_scalar($observation->{ip} || ''),
2565
                int($observation->{ttl} || 0),
2566
                clean_scalar($observation->{first_seen} || iso_now()),
2567
                clean_scalar($observation->{last_seen} || iso_now()),
2568
                int($observation->{seen_count} || 1),
2569
                clean_scalar($observation->{last_peer} || ''),
2570
            );
2571
        }
2572
    });
2573
}
2574

            
2575
sub parse_mdns_observations_yaml {
2576
    my ($text) = @_;
2577
    my %db = ( observations => [] );
2578
    my ($section, $current);
2579
    for my $line (split /\n/, $text || '') {
2580
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2581
        if ($line =~ /^observations:\s*$/) {
2582
            $section = 'observations';
2583
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2584
            $current = { key => yaml_unquote($1) };
2585
            push @{ $db{observations} }, $current;
2586
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2587
            $current->{$1} = yaml_unquote($2);
2588
        }
2589
    }
2590
    return \%db;
2591
}
2592

            
2593
sub set_schema_meta {
2594
    my ($dbh, $key, $value) = @_;
2595
    $dbh->do(
2596
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2597
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2598
        undef,
2599
        $key,
2600
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2601
        iso_now(),
2602
    );
2603
}
2604

            
Bogdan Timofte authored 4 days ago
2605
sub fqdn_for_legacy_id {
2606
    my ($dbh, $legacy_id) = @_;
2607
    return '' unless length($legacy_id || '');
2608
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2609
    return $fqdn || '';
2610
}
2611

            
2612
sub canonical_host_fqdn {
2613
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2614
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2615
    return $fqdn if length $fqdn;
2616
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2617
    for my $name (@names) {
2618
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2619
    }
2620
    for my $name (@names) {
2621
        return $name if $name =~ /\./ && !name_is_vhost($name);
2622
    }
2623
    my $id = clean_id($host->{id} || '');
2624
    return $id ? "$id.madagascar.xdev.ro" : '';
2625
}
2626

            
2627
sub legacy_id_from_fqdn {
2628
    my ($fqdn) = @_;
2629
    $fqdn = normalize_dns_name($fqdn);
2630
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2631
    $fqdn =~ s/\..*\z//;
2632
    return clean_id($fqdn);
2633
}
2634

            
2635
sub normalize_dns_name {
2636
    my ($name) = @_;
2637
    $name = lc clean_scalar($name || '');
2638
    $name =~ s/\.\z//;
2639
    return $name;
2640
}
2641

            
2642
sub name_is_vhost {
2643
    my ($name) = @_;
2644
    $name = normalize_dns_name($name);
2645
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2646
}
2647

            
2648
sub vhost_service_name {
2649
    my ($name) = @_;
2650
    $name = normalize_dns_name($name);
2651
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2652
    return '';
2653
}
2654

            
2655
sub short_alias_for_fqdn {
2656
    my ($name) = @_;
2657
    $name = normalize_dns_name($name);
2658
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2659
    return '';
2660
}
2661

            
Bogdan Timofte authored 4 days ago
2662
sub normalize_registry_policy {
2663
    my ($registry) = @_;
2664
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2665
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2666
    $registry->{policy}{runtime_database} = $opt{db};
2667
}
2668

            
2669
sub default_hosts_yaml {
2670
    return <<'YAML';
2671
version: 1
2672
updated_at: ""
2673
policy:
Bogdan Timofte authored 4 days ago
2674
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2675
hosts:
2676
YAML
2677
}
2678

            
2679
sub default_work_orders_yaml {
2680
    return <<'YAML';
2681
version: 1
2682
work_orders:
2683
YAML
2684
}
2685

            
2686
sub ensure_parent_dir {
2687
    my ($path) = @_;
2688
    my $dir = dirname($path);
2689
    make_path($dir) unless -d $dir;
2690
}
2691

            
Xdev Host Manager authored a week ago
2692
sub url_decode {
2693
    my ($value) = @_;
2694
    $value = '' unless defined $value;
2695
    $value =~ tr/+/ /;
2696
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2697
    return $value;
2698
}
2699

            
2700
sub random_hex {
2701
    my ($bytes) = @_;
2702
    if (open my $fh, '<:raw', '/dev/urandom') {
2703
        read($fh, my $raw, $bytes);
2704
        close $fh;
2705
        return unpack('H*', $raw);
2706
    }
2707
    return sha256_hex(rand() . time() . $$);
2708
}
2709

            
2710
sub iso_now {
2711
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2712
}
2713

            
Bogdan Timofte authored 6 days ago
2714
sub build_info {
2715
    my %info = (
2716
        revision => '',
2717
        branch => '',
2718
        built_at => '',
2719
        deployed_at => '',
2720
        dirty => '',
2721
    );
2722

            
2723
    if ($ENV{HOST_MANAGER_BUILD}) {
2724
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2725
        return \%info;
2726
    }
2727

            
2728
    my $build_file = "$project_dir/BUILD";
2729
    if (-f $build_file) {
2730
        for my $line (split /\n/, read_file($build_file)) {
2731
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2732
            $info{$1} = clean_scalar($2);
2733
        }
2734
        return \%info if $info{revision} || $info{built_at};
2735
    }
2736

            
2737
    my $revision = git_value('rev-parse --short=12 HEAD');
2738
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2739
    $info{revision} = $revision if $revision;
2740
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2741
    return \%info;
2742
}
2743

            
2744
sub git_value {
2745
    my ($args) = @_;
2746
    return '' unless -d "$project_dir/.git";
2747
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2748
    my $value = <$fh> || '';
2749
    close $fh;
2750
    chomp $value;
2751
    return clean_scalar($value);
2752
}
2753

            
2754
sub build_label {
2755
    my $info = build_info();
2756
    my $revision = $info->{revision} || 'unknown';
2757
    my $branch = $info->{branch} || '';
2758
    $branch = '' if $branch eq 'HEAD';
2759
    my $label = $branch ? "$branch $revision" : $revision;
2760
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2761
    return $label;
2762
}
2763

            
2764
sub build_title {
2765
    my $info = build_info();
2766
    my $label = build_label();
2767
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2768
    return $stamp ? "$label deployed $stamp" : $label;
2769
}
2770

            
Bogdan Timofte authored 4 days ago
2771
sub build_revision {
2772
    my $info = build_info();
2773
    return $info->{revision} || 'unknown';
2774
}
2775

            
2776
sub build_details {
2777
    my $info = build_info();
2778
    my %details = (
2779
        app => 'Madagascar Local Authority',
2780
        revision => $info->{revision} || 'unknown',
2781
        branch => $info->{branch} || '',
2782
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2783
        built_at => $info->{built_at} || '',
2784
        deployed_at => $info->{deployed_at} || '',
2785
        label => build_label(),
2786
        title => build_title(),
2787
    );
2788
    return json_encode(\%details);
2789
}
2790

            
Bogdan Timofte authored 6 days ago
2791
sub html_escape {
2792
    my ($value) = @_;
2793
    $value = '' unless defined $value;
2794
    $value =~ s/&/&amp;/g;
2795
    $value =~ s/</&lt;/g;
2796
    $value =~ s/>/&gt;/g;
2797
    $value =~ s/"/&quot;/g;
2798
    $value =~ s/'/&#039;/g;
2799
    return $value;
2800
}
2801

            
Xdev Host Manager authored a week ago
2802
sub app_html {
Bogdan Timofte authored 4 days ago
2803
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2804
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2805
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2806
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2807
<!doctype html>
2808
<html lang="ro">
2809
<head>
2810
  <meta charset="utf-8">
2811
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2812
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2813
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2814
  <style>
2815
    :root {
2816
      color-scheme: light;
2817
      --ink: #152033;
2818
      --muted: #647084;
2819
      --line: #d8dee8;
2820
      --soft: #f4f6f9;
2821
      --panel: #ffffff;
2822
      --accent: #1267d8;
2823
      --bad: #b42318;
2824
      --warn: #946200;
2825
      --ok: #137333;
2826
    }
2827
    * { box-sizing: border-box; }
2828
    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
2829

            
2830
    /* ── Login screen ── */
2831
    #login-screen {
2832
      display: flex;
Xdev Host Manager authored a week ago
2833
      align-items: flex-start;
Xdev Host Manager authored a week ago
2834
      justify-content: center;
2835
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2836
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2837
      background: #13182a;
Xdev Host Manager authored a week ago
2838
      overflow: auto;
Xdev Host Manager authored a week ago
2839
    }
2840
    .login-card {
Xdev Host Manager authored a week ago
2841
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2842
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2843
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2844
      background: #fff;
2845
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2846
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2847
         below the first box, sits inside the card instead of spilling past it. */
2848
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2849
      width: 100%;
Xdev Host Manager authored a week ago
2850
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2851
      min-height: 360px;
Xdev Host Manager authored a week ago
2852
      display: grid;
Xdev Host Manager authored a week ago
2853
      align-content: start;
2854
      justify-items: center;
2855
      gap: 28px;
Xdev Host Manager authored a week ago
2856
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2857
    }
Xdev Host Manager authored a week ago
2858
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2859
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2860
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2861
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2862
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2863
    }
Xdev Host Manager authored a week ago
2864
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2865
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2866
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2867
    .login-card form {
2868
      display: grid;
2869
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2870
      justify-self: center;
Bogdan Timofte authored a week ago
2871
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2872
    }
Xdev Host Manager authored a week ago
2873
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2874
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2875
       giving the password manager a username anchor and an aggregated OTP target
2876
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2877
    .pm-helper-fields {
2878
      position: absolute;
2879
      left: -10000px;
2880
      top: auto;
2881
      width: 1px;
2882
      height: 1px;
2883
      overflow: hidden;
2884
      opacity: 0.01;
2885
    }
2886
    .pm-helper-fields input {
2887
      width: 1px;
2888
      height: 1px;
2889
      padding: 0;
2890
      border: 0;
2891
    }
Bogdan Timofte authored 4 days ago
2892
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2893
       hint was what made Safari mark the whole group and re-present its OTP
2894
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2895
    .otp-row {
2896
      display: flex;
2897
      gap: var(--otp-gap);
2898
      justify-content: center;
2899
    }
Bogdan Timofte authored 4 days ago
2900
    .otp-row input {
Xdev Host Manager authored a week ago
2901
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
2902
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2903
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2904
      transition: border-color .15s, background .15s;
2905
    }
Bogdan Timofte authored 4 days ago
2906
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2907
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2908
    #login-error {
2909
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2910
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2911
    }
2912
    @media (max-width: 760px) {
2913
      .login-card {
Xdev Host Manager authored a week ago
2914
        max-width: 520px;
Xdev Host Manager authored a week ago
2915
        min-height: 0;
Bogdan Timofte authored 4 days ago
2916
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2917
        gap: 26px;
2918
      }
2919
      .login-card .brand h1 { font-size: 24px; }
2920
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2921
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2922
    }
Xdev Host Manager authored a week ago
2923
    @media (max-width: 430px) {
2924
      #login-screen { padding: 24px 16px 120px; }
2925
      .login-card {
2926
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2927
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2928
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2929
      }
Bogdan Timofte authored 4 days ago
2930
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2931
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2932
    }
2933
    @media (max-height: 720px) {
2934
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2935
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2936
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2937
    }
Xdev Host Manager authored a week ago
2938

            
2939
    /* ── App shell (hidden until authenticated) ── */
2940
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2941
    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
2942
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2943
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2944
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2945
    nav a:hover { color: var(--ink); background: var(--soft); }
2946
    nav a.active { color: var(--accent); background: #e8f0fe; }
2947
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2948
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2949
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2950
    .page { display: grid; gap: 16px; }
2951
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2952
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2953
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2954
    .panel { overflow: hidden; }
2955
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2956
    .panel-head h2 { margin: 0; font-size: 14px; }
2957
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2958
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2959
    button, input, select, textarea { font: inherit; }
2960
    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; }
2961
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2962
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2963
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2964
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2965
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2966
    textarea { min-height: 74px; resize: vertical; }
2967
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2968
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2969
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2970
    tr:hover td { background: #f8fafc; }
2971
    .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; }
2972
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2973
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2974
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
2975
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
2976
    .pill.canonical { font-weight: 700; }
2977
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
2978
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2979
    .span2 { grid-column: 1 / -1; }
2980
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2981
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2982
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2983
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2984
    .ca-fingerprint { overflow-wrap: anywhere; }
2985
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2986
    .build-control {
Bogdan Timofte authored 6 days ago
2987
      position: fixed;
2988
      right: 10px;
2989
      bottom: 8px;
2990
      z-index: 5;
Bogdan Timofte authored 4 days ago
2991
      display: inline-flex;
2992
      align-items: center;
2993
      gap: 4px;
2994
    }
2995
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2996
      color: rgba(255,255,255,.46);
2997
      background: rgba(19,24,42,.28);
2998
      border: 1px solid rgba(255,255,255,.08);
2999
      border-radius: 4px;
3000
      font-size: 10px;
3001
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
3002
    }
3003
    .build-badge {
3004
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
3005
      cursor: text;
3006
      user-select: text;
Bogdan Timofte authored 6 days ago
3007
    }
Bogdan Timofte authored 4 days ago
3008
    .build-copy {
3009
      min-height: 0;
3010
      padding: 2px 5px;
3011
      cursor: pointer;
3012
    }
3013
    .build-copy:hover {
3014
      color: rgba(255,255,255,.72);
3015
      border-color: rgba(255,255,255,.24);
3016
    }
3017
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
3018
      color: rgba(100,112,132,.58);
3019
      background: rgba(255,255,255,.72);
3020
      border-color: rgba(216,222,232,.72);
3021
    }
Bogdan Timofte authored 4 days ago
3022
    body.is-app .build-copy:hover {
3023
      color: rgba(21,32,51,.78);
3024
      border-color: rgba(100,112,132,.42);
3025
    }
Xdev Host Manager authored a week ago
3026
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
3027
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
3028
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
3029
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
3030
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
3031
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
3032
    .work-order-actions { gap: 4px; }
3033
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
3034
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
3035
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
3036
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
3037
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
3038
    .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
3039
    .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
3040
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
3041
    .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
3042
    .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; }
3043
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
3044
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
3045
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
3046
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
3047
    .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; }
3048
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
3049
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
3050
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
3051
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
3052
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
3053
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
3054
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 4 days ago
3055
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
3056
    #page-vhosts .host-tools { flex-wrap: wrap; }
3057
    #page-vhosts .host-tools input { max-width: 280px; }
3058
    #page-vhosts .stats { justify-content: flex-end; }
Bogdan Timofte authored 4 days ago
3059
    #page-vhosts .table-wrap { overflow-x: auto; }
3060
    #page-vhosts table { min-width: 1290px; }
3061
    #page-vhosts th, #page-vhosts td { overflow-wrap: normal; }
3062
    #page-vhosts .pill.vhost { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: top; }
Bogdan Timofte authored 4 days ago
3063
    .vhost-host { display: grid; gap: 2px; }
3064
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
3065
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
3066
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 4 days ago
3067
    .vhost-cert { display: grid; gap: 5px; min-width: 0; }
3068
    .vhost-cert-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: center; }
3069
    .vhost-cert-select { width: 100%; max-width: 100%; min-height: 34px; }
3070
    .vhost-cert-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-height: 24px; }
3071
    .vhost-cert-links { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
3072
    .vhost-cert-links .linkbtn { padding: 3px 7px; font-size: 12px; }
3073
    .vhost-cert-validity { font-size: 12px; }
Bogdan Timofte authored 4 days ago
3074
    .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; }
3075
    .vhost-delete { color: var(--bad); }
Bogdan Timofte authored 4 days ago
3076
    .host-inline-row td { padding: 0; background: #fff; }
3077
    .host-inline-editor-shell { background: #fff; }
3078
    .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; }
3079
    .host-inline-editor-head h2 { margin: 0; font-size: 14px; }
3080
    .host-inline-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
3081
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
3082
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
3083
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
3084
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
3085
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
3086
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
3087
      #message { max-width: 100%; }
3088
      .panel-head { align-items: stretch; flex-direction: column; }
3089
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
3090
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
3091
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 4 days ago
3092
      .host-inline-editor-head { align-items: stretch; flex-direction: column; }
3093
      .host-inline-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
3094
      .debug-controls { align-items: stretch; }
Xdev Host Manager authored a week ago
3095
      .grid { grid-template-columns: 1fr; }
3096
      table { min-width: 760px; }
3097
      .table-wrap { overflow-x: auto; }
3098
    }
3099
  </style>
3100
</head>
Bogdan Timofte authored 6 days ago
3101
<body class="is-login">
Xdev Host Manager authored a week ago
3102

            
Xdev Host Manager authored a week ago
3103
  <!-- ── Login screen ── -->
3104
  <div id="login-screen">
3105
    <div class="login-card">
3106
      <div class="brand">
3107
        <div class="icon">
Xdev Host Manager authored a week ago
3108
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3109
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3110
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3111
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3112
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3113
            <path d="M26 20h8M26 32h8M26 44h8"/>
3114
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3115
          </svg>
3116
        </div>
Xdev Host Manager authored a week ago
3117
        <h1>Madagascar Local Authority</h1>
3118
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3119
      </div>
Bogdan Timofte authored 4 days ago
3120
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3121
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3122
        <div class="pm-helper-fields" aria-hidden="true">
3123
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3124
          <input type="hidden" id="otp-hidden" name="otp">
3125
        </div>
Xdev Host Manager authored a week ago
3126
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3127
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3128
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3129
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3130
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3131
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3132
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3133
        </div>
3134
      </form>
3135
    </div>
3136
  </div>
3137

            
3138
  <!-- ── App (shown after login) ── -->
3139
  <div id="app">
3140
    <header>
Xdev Host Manager authored a week ago
3141
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3142
      <nav aria-label="Sections">
3143
        <a href="/overview" data-page-link="overview">Overview</a>
3144
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3145
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3146
        <a href="/dns" data-page-link="dns">DNS</a>
3147
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3148
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3149
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3150
      </nav>
Xdev Host Manager authored a week ago
3151
      <div class="header-right">
3152
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3153
        <span id="message" class="muted"></span>
3154
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3155
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3156
      </div>
Xdev Host Manager authored a week ago
3157
    </header>
3158
    <main>
Bogdan Timofte authored 5 days ago
3159
      <section class="page" id="page-overview" data-page="overview">
3160
        <section class="panel">
3161
          <div class="panel-head">
3162
            <h2>Overview</h2>
3163
            <div class="stats" id="stats"></div>
3164
          </div>
3165
          <div class="problems" id="problems"></div>
3166
        </section>
Xdev Host Manager authored a week ago
3167
      </section>
3168

            
Bogdan Timofte authored 5 days ago
3169
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3170
        <section class="panel">
3171
          <div class="panel-head">
3172
            <h2>Hosts</h2>
3173
            <div class="host-tools">
3174
              <input id="filter" placeholder="filter">
3175
              <button type="button" id="new-host">New host</button>
3176
            </div>
3177
          </div>
3178
          <div class="table-wrap">
3179
            <table>
3180
              <thead>
3181
                <tr>
3182
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
3183
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
3184
                  <th>Names</th>
3185
                  <th style="width: 150px">Roles</th>
3186
                  <th style="width: 110px">Monitoring</th>
3187
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3188
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3189
                </tr>
3190
              </thead>
3191
              <tbody id="hosts"></tbody>
3192
            </table>
3193
          </div>
3194
        </section>
Xdev Host Manager authored a week ago
3195
      </section>
Xdev Host Manager authored a week ago
3196

            
Bogdan Timofte authored 4 days ago
3197
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3198
        <section class="panel">
3199
          <div class="panel-head">
3200
            <h2>Vhosts</h2>
3201
            <div class="host-tools">
3202
              <input id="vhost-filter" placeholder="filter">
3203
              <div class="stats" id="vhost-stats"></div>
3204
            </div>
3205
          </div>
Bogdan Timofte authored 4 days ago
3206
          <div class="vhost-inline-editor">
3207
            <input id="vhost-new-name" placeholder="vhost fqdn">
3208
            <select id="vhost-new-host"></select>
3209
            <button type="button" id="vhost-add">Add</button>
3210
          </div>
Bogdan Timofte authored 4 days ago
3211
          <div class="table-wrap">
3212
            <table>
3213
              <thead>
3214
                <tr>
Bogdan Timofte authored 4 days ago
3215
                  <th style="width: 220px">Vhost</th>
3216
                  <th style="width: 230px">Host</th>
3217
                  <th style="width: 120px">IP</th>
3218
                  <th style="width: 160px">Derived aliases</th>
3219
                  <th style="width: 300px">Certificate</th>
3220
                  <th style="width: 100px">Monitoring</th>
3221
                  <th style="width: 80px">Status</th>
3222
                  <th style="width: 80px">Actions</th>
Bogdan Timofte authored 4 days ago
3223
                </tr>
3224
              </thead>
3225
              <tbody id="vhosts"></tbody>
3226
            </table>
3227
          </div>
3228
        </section>
3229
      </section>
3230

            
Bogdan Timofte authored 5 days ago
3231
      <section class="page" id="page-dns" data-page="dns" hidden>
3232
        <section class="toolbar">
3233
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3234
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3235
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3236
          <button id="write-tsv">Write local-hosts.tsv</button>
3237
        </section>
Xdev Host Manager authored a week ago
3238
      </section>
3239

            
Bogdan Timofte authored 5 days ago
3240
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3241
        <section class="panel">
3242
          <div class="panel-head">
3243
            <h2>Work Orders</h2>
3244
            <div class="stats" id="wo-stats"></div>
3245
          </div>
3246
          <div class="problems" id="work-orders"></div>
3247
        </section>
Xdev Host Manager authored a week ago
3248
      </section>
3249

            
Bogdan Timofte authored 5 days ago
3250
      <section class="page" id="page-ca" data-page="ca" hidden>
3251
        <section class="panel">
3252
          <div class="panel-head">
3253
            <h2>Local Certificate Authority</h2>
3254
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3255
          </div>
3256
          <div class="problems" id="ca-status"></div>
3257
        </section>
3258
        <section class="panel">
3259
          <div class="panel-head">
3260
            <h2>Issued Certificates</h2>
3261
            <div class="stats" id="ca-certs-summary"></div>
3262
          </div>
3263
          <div class="table-wrap">
3264
            <table>
3265
              <thead>
3266
                <tr>
3267
                  <th style="width: 150px">Name</th>
3268
                  <th>DNS names</th>
3269
                  <th style="width: 210px">Validity</th>
3270
                  <th style="width: 180px">Serial</th>
3271
                  <th>Fingerprint</th>
3272
                  <th style="width: 110px">Download</th>
3273
                </tr>
3274
              </thead>
3275
              <tbody id="ca-certs"></tbody>
3276
            </table>
3277
          </div>
3278
        </section>
Xdev Host Manager authored a week ago
3279
      </section>
Bogdan Timofte authored 4 days ago
3280

            
3281
      <section class="page" id="page-debug" data-page="debug" hidden>
3282
        <section class="panel">
3283
          <div class="panel-head">
3284
            <h2>Database</h2>
3285
            <div class="stats" id="debug-db-stats"></div>
3286
          </div>
3287
          <div class="toolbar">
3288
            <div class="debug-controls">
3289
              <button type="button" id="debug-db-refresh">Refresh</button>
3290
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3291
            </div>
3292
          </div>
Bogdan Timofte authored 4 days ago
3293
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3294
        </section>
3295
        <section class="debug-section">
3296
          <section class="panel">
3297
            <div class="panel-head">
3298
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3299
              <div class="debug-table-head-actions">
3300
                <div class="stats" id="debug-table-stats"></div>
3301
                <div class="debug-table-exports">
3302
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3303
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3304
                </div>
3305
              </div>
Bogdan Timofte authored 4 days ago
3306
            </div>
3307
            <div class="table-wrap" id="debug-table-rows"></div>
3308
          </section>
3309
          <section class="panel">
3310
            <div class="panel-head">
3311
              <h2>Columns</h2>
3312
            </div>
3313
            <div class="table-wrap" id="debug-table-columns"></div>
3314
          </section>
3315
          <section class="panel">
3316
            <div class="panel-head">
3317
              <h2>Indexes</h2>
3318
            </div>
3319
            <div class="table-wrap" id="debug-table-indexes"></div>
3320
          </section>
3321
          <section class="panel">
3322
            <div class="panel-head">
3323
              <h2>Foreign Keys</h2>
3324
            </div>
3325
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3326
          </section>
3327
        </section>
3328
      </section>
Bogdan Timofte authored 5 days ago
3329
    </main>
Xdev Host Manager authored a week ago
3330

            
3331
  </div>
3332

            
Bogdan Timofte authored 4 days ago
3333
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3334
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3335
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3336
  </div>
Bogdan Timofte authored 6 days ago
3337

            
Xdev Host Manager authored a week ago
3338
  <script>
Bogdan Timofte authored 4 days ago
3339
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3340
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3341
    let hostFormBusy = false;
3342
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3343
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3344

            
3345
    const $ = (id) => document.getElementById(id);
3346
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 4 days ago
3347
    const hostFormShell = document.createElement('div');
3348
    hostFormShell.id = 'host-form-shell';
3349
    hostFormShell.className = 'host-inline-editor-shell';
3350
    hostFormShell.hidden = true;
3351
    hostFormShell.innerHTML = `
3352
      <div class="host-inline-editor-head">
3353
        <h2 id="host-form-title">New host</h2>
3354
        <div class="host-inline-editor-tools">
3355
          <button type="button" id="cancel-host-form">Close</button>
3356
        </div>
3357
      </div>
3358
      <form id="host-form" class="grid">
3359
        <label>ID<input name="id" required></label>
3360
        <label>FQDN<input name="fqdn" required></label>
3361
        <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
3362
        <label>IP<input name="ip" required></label>
3363
        <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3364
        <label>Roles<input name="roles"></label>
3365
        <label>Sources<input name="sources"></label>
3366
        <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3367
        <label>Notes<input name="notes"></label>
3368
        <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
3369
        <div class="span2 form-actions">
3370
          <button class="primary" type="submit" id="save-host">Save host</button>
3371
          <button class="danger" type="button" id="delete-host">Delete host</button>
3372
        </div>
3373
      </form>`;
3374
    const hostForm = hostFormShell.querySelector('#host-form');
3375
    const hostFormTitle = hostFormShell.querySelector('#host-form-title');
3376
    const hostFormMessage = hostFormShell.querySelector('#host-form-message');
3377
    const saveHostButton = hostFormShell.querySelector('#save-host');
3378
    const deleteHostButton = hostFormShell.querySelector('#delete-host');
3379
    const cancelHostButton = hostFormShell.querySelector('#cancel-host-form');
3380
    const hostEditorRow = document.createElement('tr');
3381
    hostEditorRow.className = 'host-inline-row';
3382
    const hostEditorCell = document.createElement('td');
3383
    hostEditorCell.colSpan = 7;
3384
    hostEditorRow.appendChild(hostEditorCell);
3385
    hostEditorCell.appendChild(hostFormShell);
Bogdan Timofte authored 5 days ago
3386
    const PAGE_PATHS = {
3387
      '/': 'overview',
3388
      '/overview': 'overview',
3389
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3390
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3391
      '/dns': 'dns',
3392
      '/work-orders': 'work-orders',
3393
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3394
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3395
    };
Xdev Host Manager authored a week ago
3396

            
Bogdan Timofte authored 4 days ago
3397
    function isAuthLost(error) {
3398
      return !!(error && error.authLost);
3399
    }
3400

            
3401
    function authLostError(message) {
3402
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3403
      error.authLost = true;
3404
      return error;
3405
    }
3406

            
3407
    function handleAuthLost(message) {
3408
      state.authenticated = false;
3409
      msg('');
3410
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3411
    }
3412

            
Bogdan Timofte authored 4 days ago
3413
    async function ensureAuthenticated(message) {
3414
      if (!state.authenticated) {
3415
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3416
        return false;
3417
      }
3418
      const session = await api('/api/session');
3419
      state.authenticated = session.authenticated;
3420
      if (!state.authenticated) {
3421
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3422
        return false;
3423
      }
3424
      return true;
3425
    }
3426

            
Xdev Host Manager authored a week ago
3427
    async function api(path, options = {}) {
3428
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3429
      let body = {};
3430
      try {
3431
        body = await res.json();
3432
      } catch (_) {
3433
        body = {};
3434
      }
3435
      const errorCode = body.error || '';
3436
      if (!res.ok) {
3437
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3438
          const error = authLostError();
3439
          handleAuthLost(error.message);
3440
          throw error;
3441
        }
3442
        throw new Error(errorCode || res.statusText);
3443
      }
Xdev Host Manager authored a week ago
3444
      return body;
3445
    }
3446

            
Bogdan Timofte authored 5 days ago
3447
    function currentPage() {
3448
      return PAGE_PATHS[window.location.pathname] || 'overview';
3449
    }
3450

            
3451
    function showPage(page, push = false) {
3452
      const target = page || 'overview';
3453
      document.querySelectorAll('[data-page]').forEach(section => {
3454
        section.hidden = section.dataset.page !== target;
3455
      });
3456
      document.querySelectorAll('[data-page-link]').forEach(link => {
3457
        link.classList.toggle('active', link.dataset.pageLink === target);
3458
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3459
      });
3460
      if (push) {
3461
        const href = target === 'overview' ? '/overview' : '/' + target;
3462
        history.pushState({ page: target }, '', href);
3463
      }
Bogdan Timofte authored 4 days ago
3464
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3465
        renderDebugDatabase().catch(e => {
3466
          if (!isAuthLost(e)) msg(e.message);
3467
        });
Bogdan Timofte authored 4 days ago
3468
      }
Bogdan Timofte authored 5 days ago
3469
    }
3470

            
Xdev Host Manager authored a week ago
3471
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3472
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3473
      document.body.classList.remove('is-app');
3474
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3475
      $('app').style.display = 'none';
3476
      $('login-screen').style.display = 'flex';
3477
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3478
      clearOtp();
Xdev Host Manager authored a week ago
3479
    }
3480

            
3481
    function showApp() {
Bogdan Timofte authored 6 days ago
3482
      document.body.classList.remove('is-login');
3483
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3484
      $('login-screen').style.display = 'none';
3485
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3486
      showPage(currentPage());
Xdev Host Manager authored a week ago
3487
    }
3488

            
Xdev Host Manager authored a week ago
3489
    async function refresh() {
3490
      const session = await api('/api/session');
3491
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3492
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3493
      showApp();
Xdev Host Manager authored a week ago
3494
      const data = await api('/api/hosts');
3495
      state.hosts = data.hosts || [];
Bogdan Timofte authored 4 days ago
3496
      state.vhosts = data.vhosts || [];
3497
      state.certificates = data.certificates || [];
Xdev Host Manager authored a week ago
3498
      state.problems = data.problems || [];
3499
      render(data);
Xdev Host Manager authored a week ago
3500
      await renderCa();
Xdev Host Manager authored a week ago
3501
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3502
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3503
    }
3504

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

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

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

            
3518
      renderHosts();
Bogdan Timofte authored 4 days ago
3519
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3520
      renderVhosts();
Xdev Host Manager authored a week ago
3521
    }
3522

            
Xdev Host Manager authored a week ago
3523
    async function renderCa() {
3524
      try {
3525
        const status = await api('/api/ca/status');
3526
        if (!status.initialized) {
3527
          $('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
3528
          $('ca-certs-summary').innerHTML = '';
3529
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3530
          return;
3531
        }
3532
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 4 days ago
3533
        state.certificates = certs.map(cert => ({
3534
          ...cert,
3535
          id: cert.id || cert.name || '',
3536
          name: cert.name || cert.id || '',
3537
          has_private_key: !!cert.has_private_key
3538
        }));
Bogdan Timofte authored 5 days ago
3539
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3540
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3541
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3542
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3543
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3544
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3545
            <div>
3546
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3547
              <span>${certs.length} issued certificate(s)</span>
3548
            </div>
Xdev Host Manager authored a week ago
3549
          </div>`;
Bogdan Timofte authored 5 days ago
3550
        $('ca-certs-summary').innerHTML = [
3551
          ['issued', certs.length],
3552
          ['expiring', certs.filter(cert => {
3553
            const days = daysUntil(cert.not_after);
3554
            return days !== null && days >= 0 && days <= 30;
3555
          }).length],
3556
          ['expired', certs.filter(cert => {
3557
            const days = daysUntil(cert.not_after);
3558
            return days !== null && days < 0;
3559
          }).length],
3560
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3561
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3562
          const days = daysUntil(cert.not_after);
3563
          const dnsNames = cert.dns_names || [];
3564
          const dnsHtml = dnsNames.length
3565
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3566
            : '<span class="muted">No DNS SANs reported.</span>';
3567
          return `<tr>
3568
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3569
            <td>${dnsHtml}</td>
3570
            <td>
3571
              <div class="ca-detail">
3572
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3573
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3574
              </div>
3575
            </td>
3576
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3577
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
Bogdan Timofte authored 4 days ago
3578
            <td>
3579
              <div class="vhost-cert-links">
3580
                <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a>
3581
                ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(cert.name || '')}.key">key</a>` : ''}
3582
              </div>
3583
            </td>
Bogdan Timofte authored 5 days ago
3584
          </tr>`;
3585
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3586
      } catch (e) {
Bogdan Timofte authored 4 days ago
3587
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3588
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3589
        $('ca-certs-summary').innerHTML = '';
3590
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3591
      }
3592
    }
3593

            
Bogdan Timofte authored 5 days ago
3594
    function daysUntil(dateText) {
3595
      const time = Date.parse(dateText || '');
3596
      if (!Number.isFinite(time)) return null;
3597
      return Math.ceil((time - Date.now()) / 86400000);
3598
    }
3599

            
3600
    function certStatusClass(days) {
3601
      if (days === null) return '';
3602
      if (days < 0) return 'bad';
3603
      if (days <= 30) return 'warn';
3604
      return 'ok';
3605
    }
3606

            
3607
    function certStatusLabel(days) {
3608
      if (days === null) return 'validity unknown';
3609
      if (days < 0) return 'expired';
3610
      if (days === 0) return 'expires today';
3611
      return `${days}d remaining`;
3612
    }
3613

            
Xdev Host Manager authored a week ago
3614
    async function renderWorkOrders() {
3615
      try {
3616
        const data = await api('/api/work-orders');
3617
        state.workOrders = data.work_orders || [];
3618
        $('wo-stats').innerHTML = [
3619
          ['pending', data.counts.pending],
3620
          ['total', data.counts.work_orders],
3621
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3622

            
3623
        if (!state.workOrders.length) {
3624
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3625
          return;
3626
        }
3627

            
3628
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3629
          const checklist = wo.checklist || [];
3630
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3631
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3632
          const checklistHtml = checklist.map(item => {
3633
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3634
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3635
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3636
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3637
            </label>`;
3638
          }).join('');
Xdev Host Manager authored a week ago
3639
          const actions = (wo.actions || []).map(a => {
3640
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3641
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3642
          }).join('');
3643
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3644
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3645
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3646
            : '';
Bogdan Timofte authored 6 days ago
3647
          return `<div class="problem work-order-card">
3648
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3649
              <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
3650
              ${button}
3651
            </div>
Bogdan Timofte authored 6 days ago
3652
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3653
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3654
            <div class="work-order-checklist">${checklistHtml}</div>
3655
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3656
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3657
          </div>`;
3658
        }).join('');
Xdev Host Manager authored a week ago
3659
        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
3660
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3661
      } catch (e) {
Bogdan Timofte authored 4 days ago
3662
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3663
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3664
      }
3665
    }
3666

            
Bogdan Timofte authored 4 days ago
3667
    async function renderDebugDatabase() {
3668
      if (!state.authenticated) return;
3669
      const data = await api('/api/debug/database/tables');
3670
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3671
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3672
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3673
      $('debug-db-stats').innerHTML = [
3674
        ['tables', data.counts ? data.counts.tables : tables.length],
3675
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3676
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3677
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3678
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3679
      if (selected) {
3680
        await renderDebugTable(selected);
3681
      } else {
3682
        clearDebugTable();
3683
      }
3684
    }
3685

            
Bogdan Timofte authored 4 days ago
3686
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3687
      $('debug-db-tables').innerHTML = tables.length
3688
        ? tables.map(table => {
3689
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3690
            const ref = debugTableReference(database, table.name);
3691
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3692
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3693
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3694
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3695
              </button>
Bogdan Timofte authored 4 days ago
3696
              <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
3697
            </div>`;
Bogdan Timofte authored 4 days ago
3698
          }).join('')
3699
        : '<div class="ca-empty muted">No database tables found.</div>';
3700
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3701
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3702
          if (!isAuthLost(e)) msg(e.message);
3703
        }));
3704
      });
Bogdan Timofte authored 4 days ago
3705
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3706
        button.addEventListener('click', async () => {
3707
          try {
3708
            await copyText(button.dataset.debugTableRef || '');
3709
            msg('table reference copied');
3710
          } catch (e) {
3711
            msg('copy failed');
3712
          }
3713
        });
3714
      });
3715
    }
3716

            
3717
    function debugTableReference(database, tableName) {
3718
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3719
    }
3720

            
3721
    async function selectDebugTable(tableName) {
3722
      state.debugTable = tableName || '';
3723
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3724
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3725
        const card = button.closest('.debug-table-card');
3726
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3727
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3728
      });
3729
      if (state.debugTable) await renderDebugTable(state.debugTable);
3730
    }
3731

            
3732
    function clearDebugTable() {
3733
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3734
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3735
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3736
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3737
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3738
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3739
    }
3740

            
3741
    async function renderDebugTable(tableName) {
3742
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3743
      if (data.error) throw new Error(data.error);
3744
      $('debug-table-stats').innerHTML = [
3745
        ['table', data.table || tableName],
3746
        ['rows', data.row_count || 0],
3747
        ['shown', (data.rows || []).length],
3748
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3749
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3750
      renderDebugRows(data);
3751
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3752
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3753
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3754
    }
3755

            
Bogdan Timofte authored 4 days ago
3756
    function updateDebugExportLinks(tableName) {
3757
      const encoded = encodeURIComponent(tableName || '');
3758
      [
3759
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3760
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3761
      ].forEach(([id, href]) => {
3762
        const link = $(id);
3763
        const enabled = !!tableName;
3764
        link.href = enabled ? href : '#';
3765
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3766
      });
3767
    }
3768

            
Bogdan Timofte authored 4 days ago
3769
    function renderDebugRows(data) {
3770
      const rows = data.rows || [];
3771
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3772
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3773
    }
3774

            
3775
    function renderDebugObjectTable(rows, preferredKeys) {
3776
      const keys = preferredKeys && preferredKeys.length
3777
        ? preferredKeys
3778
        : Array.from(rows.reduce((set, row) => {
3779
            Object.keys(row || {}).forEach(key => set.add(key));
3780
            return set;
3781
          }, new Set()));
3782
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3783
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3784
      const body = rows.length
3785
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3786
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3787
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3788
    }
3789

            
3790
    function debugCell(value) {
3791
      if (value === null || value === undefined) return 'NULL';
3792
      if (Array.isArray(value)) return value.join(', ');
3793
      if (typeof value === 'object') return JSON.stringify(value);
3794
      return String(value);
3795
    }
3796

            
Xdev Host Manager authored a week ago
3797
    async function updateWorkOrderChecklist(id, itemId, checked) {
3798
      try {
3799
        await api('/api/work-orders/checklist', {
3800
          method: 'POST',
3801
          headers: { 'Content-Type': 'application/json' },
3802
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3803
        });
3804
        msg('work order updated');
3805
        await refresh();
Bogdan Timofte authored 4 days ago
3806
      } catch (e) {
3807
        if (isAuthLost(e)) return;
3808
        msg(e.message);
3809
        await refresh().catch(refreshError => {
3810
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3811
        });
3812
      }
Xdev Host Manager authored a week ago
3813
    }
3814

            
Xdev Host Manager authored a week ago
3815
    async function confirmWorkOrder(id) {
3816
      const typed = prompt(`Type ${id} to confirm this work order`);
3817
      if (typed !== id) return;
3818
      try {
3819
        await api('/api/work-orders/confirm', {
3820
          method: 'POST',
3821
          headers: { 'Content-Type': 'application/json' },
3822
          body: JSON.stringify({ id, confirm: typed })
3823
        });
3824
        msg('work order confirmed; local-hosts.tsv written');
3825
        await refresh();
Bogdan Timofte authored 4 days ago
3826
      } catch (e) {
3827
        if (isAuthLost(e)) return;
3828
        msg(e.message);
3829
      }
Xdev Host Manager authored a week ago
3830
    }
3831

            
Xdev Host Manager authored a week ago
3832
    function renderHosts() {
3833
      const filter = $('filter').value.toLowerCase();
3834
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3835
        .slice()
3836
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3837
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3838
        .map(h => {
3839
          const problems = state.problems.filter(p => p.host_id === h.id);
3840
          const cls = problems.length ? 'warn' : 'ok';
3841
          return `<tr data-id="${escapeHtml(h.id)}">
Bogdan Timofte authored 4 days ago
3842
            <td>${escapeHtml(h.id)}</td>
Bogdan Timofte authored 4 days ago
3843
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
3844
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3845
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3846
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3847
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 4 days ago
3848
            <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
Xdev Host Manager authored a week ago
3849
          </tr>`;
3850
        }).join('');
Bogdan Timofte authored 4 days ago
3851
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3852
        editHost(button.dataset.edit).catch(e => {
3853
          if (!isAuthLost(e)) msg(e.message);
3854
        });
3855
      }));
Bogdan Timofte authored 4 days ago
3856
      mountHostEditor();
Xdev Host Manager authored a week ago
3857
    }
3858

            
Bogdan Timofte authored 4 days ago
3859
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3860
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3861
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3862
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3863
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3864
    }
3865

            
3866
    function vhostRows() {
Bogdan Timofte authored 4 days ago
3867
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
3868
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3869
        vhost,
3870
        host_id: host.id || '',
3871
        host_fqdn: host.fqdn || '',
3872
        ip: host.ip || '',
3873
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3874
        monitoring: host.monitoring || '',
3875
        status: host.status || '',
Bogdan Timofte authored 4 days ago
3876
        certificate_id: '',
3877
        certificate: null,
Bogdan Timofte authored 4 days ago
3878
      })));
3879
    }
3880

            
3881
    function renderVhosts() {
3882
      const input = $('vhost-filter');
3883
      const filter = input ? input.value.toLowerCase() : '';
3884
      const rows = vhostRows()
3885
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3886
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3887
      $('vhost-stats').innerHTML = [
3888
        ['shown', rows.length],
3889
        ['total', vhostRows().length],
3890
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3891
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3892
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
Bogdan Timofte authored 4 days ago
3893
        <td>
3894
          <div class="vhost-host">
3895
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3896
              ${renderVhostHostOptions(row.host_fqdn)}
3897
            </select>
3898
          </div>
3899
        </td>
Bogdan Timofte authored 4 days ago
3900
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
3901
        <td><div class="vhost-pill-row">${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</div></td>
Bogdan Timofte authored 4 days ago
3902
        <td>${renderVhostCertificateCell(row)}</td>
Bogdan Timofte authored 4 days ago
3903
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3904
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
3905
        <td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
Bogdan Timofte authored 4 days ago
3906
      </tr>`).join('') : '<tr><td colspan="8" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
3907
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
3908
        select.addEventListener('change', () => {
3909
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
3910
            if (!isAuthLost(e)) msg(e.message);
3911
            select.value = select.dataset.currentHost || '';
3912
          });
Bogdan Timofte authored 4 days ago
3913
        });
Bogdan Timofte authored 4 days ago
3914
      });
Bogdan Timofte authored 4 days ago
3915
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
3916
        button.addEventListener('click', () => {
3917
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
3918
            if (!isAuthLost(e)) msg(e.message);
3919
          });
3920
        });
3921
      });
Bogdan Timofte authored 4 days ago
3922
      document.querySelectorAll('[data-vhost-cert-select]').forEach(select => {
3923
        select.addEventListener('change', () => {
3924
          setVhostCertificateFromSelect(select).catch(e => {
3925
            if (!isAuthLost(e)) msg(e.message);
3926
            select.value = select.dataset.currentCertificate || '';
3927
          });
3928
        });
3929
      });
3930
      document.querySelectorAll('[data-vhost-cert-issue]').forEach(button => {
3931
        button.addEventListener('click', () => {
Bogdan Timofte authored 4 days ago
3932
          issueVhostCertificate(button.dataset.vhostCertIssue || '', button.dataset.currentCertificate || '', button).catch(e => {
Bogdan Timofte authored 4 days ago
3933
            if (!isAuthLost(e)) msg(e.message);
3934
          });
3935
        });
3936
      });
3937
    }
3938

            
3939
    function renderVhostCertificateCell(row) {
3940
      const cert = row.certificate || {};
3941
      const certId = row.certificate_id || cert.id || cert.name || '';
3942
      const links = certId ? `<div class="vhost-cert-links">
3943
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
3944
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
3945
      </div>` : '';
3946
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
3947
      return `<div class="vhost-cert">
3948
        <div class="vhost-cert-main">
3949
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
3950
            ${renderCertificateOptions(certId)}
3951
          </select>
3952
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
3953
        </div>
3954
        <div class="vhost-cert-meta">${links}${validity}</div>
3955
      </div>`;
Bogdan Timofte authored 4 days ago
3956
    }
3957

            
3958
    function renderVhostEditor() {
3959
      const select = $('vhost-new-host');
3960
      const current = select.value || '';
3961
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3962
    }
3963

            
3964
    function renderVhostHostOptions(selectedHostFqdn) {
3965
      return state.hosts
3966
        .slice()
3967
        .filter(host => (host.status || '') !== 'retired')
3968
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3969
        .map(host => {
3970
          const fqdn = host.fqdn || '';
3971
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
3972
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
3973
        }).join('');
Bogdan Timofte authored 4 days ago
3974
    }
3975

            
Bogdan Timofte authored 4 days ago
3976
    function renderCertificateOptions(selectedCertificateId) {
3977
      const certs = (state.certificates || [])
3978
        .slice()
3979
        .sort((a, b) => String(a.name || a.id || '').localeCompare(String(b.name || b.id || '')));
3980
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
3981
        const id = cert.id || cert.name || '';
3982
        const label = cert.name || cert.id || '';
3983
        const selected = id === selectedCertificateId ? ' selected' : '';
3984
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
3985
      }));
3986
      return options.join('');
3987
    }
3988

            
Bogdan Timofte authored 4 days ago
3989
    function shortAliasForFqdn(name) {
3990
      const suffix = '.madagascar.xdev.ro';
3991
      name = String(name || '').toLowerCase();
3992
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3993
    }
3994

            
Bogdan Timofte authored 4 days ago
3995
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
3996
      const vhost = select.dataset.vhostSelect || '';
3997
      const fromHost = select.dataset.currentHost || '';
3998
      const toHost = select.value || '';
3999
      if (!vhost || !toHost || toHost === fromHost) return;
4000
      select.disabled = true;
4001
      try {
4002
        await api('/api/vhosts/reassign', {
4003
          method: 'POST',
4004
          headers: { 'Content-Type': 'application/json' },
4005
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
4006
        });
4007
        msg(`vhost ${vhost} moved`);
4008
        await refresh();
4009
      } finally {
4010
        select.disabled = false;
4011
      }
4012
    }
4013

            
Bogdan Timofte authored 4 days ago
4014
    async function addVhostInline() {
4015
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4016
      const nameInput = $('vhost-new-name');
4017
      const hostSelect = $('vhost-new-host');
4018
      const vhost = (nameInput.value || '').trim().toLowerCase();
4019
      const hostFqdn = hostSelect.value || '';
4020
      if (!vhost || !hostFqdn) return;
4021
      $('vhost-add').disabled = true;
4022
      nameInput.disabled = true;
4023
      hostSelect.disabled = true;
4024
      try {
4025
        await api('/api/vhosts/upsert', {
4026
          method: 'POST',
4027
          headers: { 'Content-Type': 'application/json' },
4028
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4029
        });
4030
        nameInput.value = '';
4031
        msg(`vhost ${vhost} saved`);
4032
        await refresh();
4033
      } finally {
4034
        $('vhost-add').disabled = false;
4035
        nameInput.disabled = false;
4036
        hostSelect.disabled = false;
4037
      }
4038
    }
4039

            
Bogdan Timofte authored 4 days ago
4040
    async function setVhostCertificateFromSelect(select) {
4041
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) {
4042
        select.value = select.dataset.currentCertificate || '';
4043
        return;
4044
      }
4045
      const vhost = select.dataset.vhostCertSelect || '';
4046
      const certificateId = select.value || '';
4047
      const current = select.dataset.currentCertificate || '';
4048
      if (!vhost || certificateId === current) return;
4049
      if (!certificateId && current && !confirm(`Clear certificate from ${vhost}?`)) {
4050
        select.value = current;
4051
        return;
4052
      }
4053
      select.disabled = true;
4054
      try {
4055
        await api('/api/vhosts/certificate', {
4056
          method: 'POST',
4057
          headers: { 'Content-Type': 'application/json' },
4058
          body: JSON.stringify({ vhost_fqdn: vhost, certificate_id: certificateId }),
4059
        });
4060
        msg(certificateId ? `certificate ${certificateId} linked to ${vhost}` : `certificate cleared from ${vhost}`);
4061
        await refresh();
4062
      } finally {
4063
        select.disabled = false;
4064
      }
4065
    }
4066

            
Bogdan Timofte authored 4 days ago
4067
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4068
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4069
      if (!vhost) return;
4070
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4071
      if (button) button.disabled = true;
4072
      try {
4073
        const result = await api('/api/vhosts/issue-certificate', {
4074
          method: 'POST',
4075
          headers: { 'Content-Type': 'application/json' },
4076
          body: JSON.stringify({ vhost_fqdn: vhost }),
4077
        });
4078
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4079
        await refresh();
4080
      } finally {
4081
        if (button) button.disabled = false;
4082
      }
4083
    }
4084

            
Bogdan Timofte authored 4 days ago
4085
    async function deleteVhostInline(vhost) {
4086
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4087
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4088
      await api('/api/vhosts/delete', {
4089
        method: 'POST',
4090
        headers: { 'Content-Type': 'application/json' },
4091
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4092
      });
4093
      msg(`vhost ${vhost} deleted`);
4094
      await refresh();
4095
    }
4096

            
Bogdan Timofte authored 4 days ago
4097
    async function editHost(id) {
4098
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4099
      const host = state.hosts.find(h => h.id === id);
4100
      if (!host) return;
Bogdan Timofte authored 4 days ago
4101
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4102
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4103
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4104
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4105
      hostField('roles').value = (host.roles || []).join(' ');
4106
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4107
      activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4108
    }
4109

            
Bogdan Timofte authored 4 days ago
4110
    async function newHost() {
4111
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4112
      if (!canSwitchHostEditor('__new__')) return;
4113
      resetHostForm(true);
4114
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4115
    }
4116

            
Bogdan Timofte authored 4 days ago
4117
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4118
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4119
      hostEditorTarget = target || '';
4120
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4121
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4122
      renderHosts();
4123
      hostFormSnapshot = hostFormState();
4124
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4125
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4126
    }
4127

            
Bogdan Timofte authored 4 days ago
4128
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4129
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4130
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4131
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4132
      hostField('status').value = 'active';
4133
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4134
      hostFormSnapshot = force ? '' : hostFormState();
4135
    }
4136

            
4137
    function closeHostForm(force = false) {
4138
      if (hostFormBusy && !force) return;
4139
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4140
      hostEditorTarget = '';
4141
      hostFormMode = 'new';
4142
      hostFormSnapshot = '';
4143
      clearHostFormMessage();
4144
      syncHostFormActions();
4145
      mountHostEditor();
4146
    }
4147

            
4148
    function canSwitchHostEditor(target) {
4149
      if (hostFormBusy) return false;
4150
      if (!hostEditorTarget) return true;
4151
      if (!hostFormDirty()) return true;
4152
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4153
      return confirm('Discard unsaved host changes?');
4154
    }
4155

            
4156
    function mountHostEditor() {
4157
      hostEditorRow.remove();
4158
      if (!hostEditorTarget) {
4159
        hostFormShell.hidden = true;
4160
        return;
4161
      }
4162
      hostEditorCell.colSpan = 7;
4163
      const tbody = $('hosts');
4164
      if (!tbody) return;
4165
      if (hostEditorTarget === '__new__') {
4166
        tbody.prepend(hostEditorRow);
4167
      } else {
4168
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4169
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4170
        if (targetRow) targetRow.after(hostEditorRow);
4171
        else tbody.prepend(hostEditorRow);
4172
      }
4173
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4174
    }
4175

            
4176
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4177
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4178
    }
4179

            
4180
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4181
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4182
    }
4183

            
4184
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4185
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4186
    }
4187

            
4188
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4189
      hostFormBusy = !!busy;
4190
      syncHostFormActions();
4191
    }
4192

            
4193
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4194
      saveHostButton.disabled = hostFormBusy;
4195
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4196
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4197
    }
4198

            
4199
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4200
      hostFormMessage.textContent = text || '';
4201
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4202
    }
4203

            
4204
    function clearHostFormMessage() {
4205
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4206
    }
4207

            
4208
    function formObject(form) {
4209
      return Object.fromEntries(new FormData(form).entries());
4210
    }
4211

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

            
Bogdan Timofte authored 6 days ago
4217
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4218

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

            
4224
    if (loginAccount) {
4225
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4226
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4227
      loginAccount.addEventListener('input', () => {
4228
        const value = (loginAccount.value || '').trim();
4229
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4230
      });
4231
    }
4232

            
Xdev Host Manager authored a week ago
4233
    function setOtpDigit(idx, value) {
4234
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4235
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4236
      otpDigits[idx].classList.toggle('filled', !!digit);
4237
    }
4238

            
Bogdan Timofte authored 4 days ago
4239
    // Move focus to the next empty box: forward from idx, then wrapping to the
4240
    // start. This lets out-of-order entry continue (e.g. after the last box,
4241
    // jump back to the first still-empty box). Stays put when all boxes are full.
4242
    function advanceFocus(idx) {
4243
      for (let i = idx + 1; i < otpDigits.length; i++) {
4244
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4245
      }
4246
      for (let i = 0; i <= idx; i++) {
4247
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4248
      }
4249
    }
4250

            
Bogdan Timofte authored 4 days ago
4251
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4252
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4253
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4254
      const digits = (text || '').replace(/\D/g, '').split('');
4255
      if (!digits.length) return;
4256
      let last = startIdx;
4257
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4258
        last = startIdx + i;
4259
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4260
      }
Bogdan Timofte authored 4 days ago
4261
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4262
      advanceFocus(last);
Xdev Host Manager authored a week ago
4263
      maybeSubmitOtp();
4264
    }
4265

            
Bogdan Timofte authored 4 days ago
4266
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4267
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4268
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4269
    function maybeSubmitOtp() {
4270
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4271
    }
4272
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4273
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4274
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4275
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4276
      // an unknown operator, so Safari's autofill anchor on the username stays.
4277
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4278
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4279
    }
4280

            
Bogdan Timofte authored 4 days ago
4281
    otpDigits.forEach((input, idx) => {
4282
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4283
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4284
        // A single box may receive several digits at once (autofill / typing fast).
4285
        if (input.value.replace(/\D/g, '').length > 1) {
4286
          fillOtp(input.value, idx);
4287
          return;
4288
        }
4289
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4290
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4291
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4292
        maybeSubmitOtp();
4293
      });
Bogdan Timofte authored 4 days ago
4294

            
4295
      input.addEventListener('paste', (e) => {
4296
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4297
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4298
        const text = (e.clipboardData || window.clipboardData).getData('text');
4299
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4300
      });
Bogdan Timofte authored 4 days ago
4301

            
4302
      input.addEventListener('keydown', (e) => {
4303
        if (e.key === 'Backspace') {
4304
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4305
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4306
          if (input.value) { setOtpDigit(idx, ''); }
4307
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4308
          syncOtpFields();
4309
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4310
          e.preventDefault();
4311
          otpDigits[idx - 1].focus();
4312
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4313
          e.preventDefault();
4314
          otpDigits[idx + 1].focus();
4315
        }
4316
      });
4317
    });
4318

            
Bogdan Timofte authored 4 days ago
4319
    // Focus the first OTP box only for a returning operator (username known).
4320
    // For an unknown operator, leave focus on the username field so Safari can
4321
    // present its OTP autofill anchored there without being dismissed by a focus
4322
    // change (pbx-admin pattern).
4323
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4324
    else if (loginAccount) loginAccount.focus();
4325
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4326

            
Bogdan Timofte authored 5 days ago
4327
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4328
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4329
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4330
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4331
        showPage(link.dataset.pageLink, true);
4332
      });
4333
    });
4334

            
Bogdan Timofte authored 4 days ago
4335
    window.addEventListener('popstate', () => {
4336
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4337
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4338
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4339
    });
Bogdan Timofte authored 5 days ago
4340

            
Bogdan Timofte authored 4 days ago
4341
    async function copyText(text) {
4342
      if (navigator.clipboard && window.isSecureContext) {
4343
        await navigator.clipboard.writeText(text);
4344
        return;
4345
      }
4346
      const input = document.createElement('textarea');
4347
      input.value = text;
4348
      input.setAttribute('readonly', '');
4349
      input.style.position = 'fixed';
4350
      input.style.left = '-10000px';
4351
      document.body.appendChild(input);
4352
      input.select();
4353
      document.execCommand('copy');
4354
      document.body.removeChild(input);
4355
    }
4356

            
4357
    $('copy-build').addEventListener('click', async () => {
4358
      try {
4359
        await copyText($('copy-build').dataset.buildDetails || '');
4360
        if (state.authenticated) msg('build details copied');
4361
      } catch (e) {
4362
        if (state.authenticated) msg('copy failed');
4363
      }
4364
    });
4365

            
Xdev Host Manager authored a week ago
4366
    $('login-form').addEventListener('submit', async (event) => {
4367
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4368
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4369
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4370
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4371
      try {
Xdev Host Manager authored a week ago
4372
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4373
        await refresh();
Xdev Host Manager authored a week ago
4374
      } catch (e) {
4375
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4376
      } finally {
Xdev Host Manager authored a week ago
4377
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4378
      }
Xdev Host Manager authored a week ago
4379
    });
4380

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

            
Bogdan Timofte authored 4 days ago
4386
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4387
      if (!isAuthLost(e)) msg(e.message);
4388
    }));
Xdev Host Manager authored a week ago
4389
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4390
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4391
    $('vhost-add').addEventListener('click', () => {
4392
      addVhostInline().catch(e => {
4393
        if (!isAuthLost(e)) msg(e.message);
4394
      });
4395
    });
4396
    $('vhost-new-name').addEventListener('keydown', (event) => {
4397
      if (event.key !== 'Enter') return;
4398
      event.preventDefault();
4399
      addVhostInline().catch(e => {
4400
        if (!isAuthLost(e)) msg(e.message);
4401
      });
4402
    });
Bogdan Timofte authored 4 days ago
4403
    $('new-host').addEventListener('click', () => {
4404
      newHost().catch(e => {
4405
        if (!isAuthLost(e)) msg(e.message);
4406
      });
4407
    });
Bogdan Timofte authored 4 days ago
4408
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4409
      if (!isAuthLost(e)) msg(e.message);
4410
    }));
Bogdan Timofte authored 4 days ago
4411
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4412

            
Bogdan Timofte authored 4 days ago
4413
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4414
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4415
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4416
      setHostFormBusy(true);
4417
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4418
      try {
Bogdan Timofte authored 4 days ago
4419
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4420
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4421
        msg('host saved');
4422
        await refresh();
Bogdan Timofte authored 4 days ago
4423
        const host = state.hosts.find(entry => entry.id === savedId);
4424
        if (host) {
4425
          clearHostFormMessage();
4426
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4427
          hostField('aliases').value = (host.aliases || []).join('\n');
4428
          hostField('roles').value = (host.roles || []).join(' ');
4429
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4430
          activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 4 days ago
4431
        } else {
Bogdan Timofte authored 4 days ago
4432
          closeHostForm(true);
Bogdan Timofte authored 4 days ago
4433
        }
Bogdan Timofte authored 5 days ago
4434
      } catch (e) {
Bogdan Timofte authored 4 days ago
4435
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4436
        setHostFormMessage(e.message, true);
4437
        msg(e.message);
4438
      } finally {
4439
        setHostFormBusy(false);
4440
      }
4441
    });
4442

            
Bogdan Timofte authored 4 days ago
4443
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4444
      setHostFormMessage('Complete the required host fields before saving.', true);
4445
    }, true);
4446

            
Bogdan Timofte authored 4 days ago
4447
    hostForm.addEventListener('input', () => {
4448
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4449
    });
4450

            
Bogdan Timofte authored 4 days ago
4451
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4452
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4453
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4454
      setHostFormBusy(true);
4455
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4456
      try {
4457
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4458
        msg('host deleted');
4459
        await refresh();
Bogdan Timofte authored 4 days ago
4460
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4461
      } catch (e) {
Bogdan Timofte authored 4 days ago
4462
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4463
        setHostFormMessage(e.message, true);
4464
        msg(e.message);
4465
      } finally {
4466
        setHostFormBusy(false);
4467
      }
Xdev Host Manager authored a week ago
4468
    });
4469

            
Bogdan Timofte authored 4 days ago
4470
    resetHostForm(true);
4471
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4472

            
Xdev Host Manager authored a week ago
4473
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4474
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4475
      try {
4476
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4477
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4478
      } catch (e) {
4479
        if (!isAuthLost(e)) msg(e.message);
4480
      }
Xdev Host Manager authored a week ago
4481
    });
4482

            
Bogdan Timofte authored 4 days ago
4483
    refresh().catch(e => {
4484
      if (!isAuthLost(e)) showLogin(e.message);
4485
    });
Xdev Host Manager authored a week ago
4486
  </script>
4487
</body>
4488
</html>
4489
HTML
Bogdan Timofte authored 6 days ago
4490
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4491
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4492
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4493
    return $html;
Xdev Host Manager authored a week ago
4494
}