LocalAuthority / scripts / host_manager.pl
Newer Older
4367 lines | 169.933kb
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} || ''),
468
            has_private_key => json_bool(-f ca_issued_key_path($cert_id) ? 1 : 0),
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) ],
508
            has_private_key => json_bool(-f ca_issued_key_path($id) ? 1 : 0),
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

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

            
1185
sub ca_manager_json {
1186
    my ($command) = @_;
1187
    my $out = ca_manager_output($command);
Bogdan Timofte authored 4 days ago
1188
    $out ||= $command eq 'list-json' ? '[]' : '{}';
1189
    sync_certificates_from_json($out) if $command eq 'list-json';
1190
    return $out;
1191
}
1192

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

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

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

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

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

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

            
Xdev Host Manager authored a week ago
1441
sub request_payload {
1442
    my ($headers, $body) = @_;
1443
    my $type = $headers->{'content-type'} || '';
1444
    if ($type =~ m{application/json}) {
1445
        return json_decode($body || '{}');
1446
    }
1447
    return { parse_params($body || '') };
1448
}
1449

            
1450
sub json_bool {
1451
    my ($value) = @_;
1452
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1453
}
1454

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

            
1477
sub json_string {
1478
    my ($value) = @_;
1479
    $value = '' unless defined $value;
1480
    $value =~ s/\\/\\\\/g;
1481
    $value =~ s/"/\\"/g;
1482
    $value =~ s/\n/\\n/g;
1483
    $value =~ s/\r/\\r/g;
1484
    $value =~ s/\t/\\t/g;
1485
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1486
    return qq("$value");
1487
}
1488

            
1489
sub json_decode {
1490
    my ($text) = @_;
1491
    my $i = 0;
1492
    my $len = length($text);
1493
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1494

            
1495
    $skip_ws = sub {
1496
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1497
    };
1498

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

            
1536
    $parse_number = sub {
1537
        my $start = $i;
1538
        $i++ if substr($text, $i, 1) eq '-';
1539
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1540
        if ($i < $len && substr($text, $i, 1) eq '.') {
1541
            $i++;
1542
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1543
        }
1544
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1545
            $i++;
1546
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1547
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1548
        }
1549
        return 0 + substr($text, $start, $i - $start);
1550
    };
1551

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

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

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

            
1617
    my $value = $parse_value->();
1618
    $skip_ws->();
1619
    die "Trailing JSON content\n" if $i != $len;
1620
    return $value;
1621
}
1622

            
1623
sub parse_params {
1624
    my ($text) = @_;
1625
    my %out;
1626
    for my $pair (split /&/, $text) {
1627
        next unless length $pair;
1628
        my ($k, $v) = split /=/, $pair, 2;
1629
        $out{url_decode($k)} = url_decode($v || '');
1630
    }
1631
    return %out;
1632
}
1633

            
1634
sub clean_id {
1635
    my ($value) = @_;
1636
    $value = lc clean_scalar($value);
1637
    $value =~ s/[^a-z0-9_.-]+/-/g;
1638
    $value =~ s/^-+|-+$//g;
1639
    return $value;
1640
}
1641

            
Bogdan Timofte authored 4 days ago
1642
sub clean_certificate_id {
1643
    my ($value) = @_;
1644
    $value = clean_scalar($value);
1645
    return '' unless length $value;
1646
    return $value =~ /\A[A-Za-z0-9_.-]+\z/ ? $value : '';
1647
}
1648

            
Xdev Host Manager authored a week ago
1649
sub clean_scalar {
1650
    my ($value) = @_;
1651
    $value = '' unless defined $value;
1652
    $value =~ s/[\r\n\t]+/ /g;
1653
    $value =~ s/^\s+|\s+$//g;
1654
    return $value;
1655
}
1656

            
1657
sub clean_list {
1658
    my ($value) = @_;
1659
    return () unless defined $value;
1660
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1661
    my @clean;
1662
    for my $item (@items) {
1663
        $item = clean_scalar($item);
1664
        push @clean, $item if length $item;
1665
    }
1666
    return @clean;
1667
}
1668

            
1669
sub yq {
1670
    my ($value) = @_;
1671
    $value = '' unless defined $value;
1672
    $value =~ s/\\/\\\\/g;
1673
    $value =~ s/"/\\"/g;
1674
    return qq("$value");
1675
}
1676

            
1677
sub yaml_unquote {
1678
    my ($value) = @_;
1679
    $value = '' unless defined $value;
1680
    $value =~ s/^\s+|\s+$//g;
1681
    if ($value =~ /^"(.*)"$/) {
1682
        $value = $1;
1683
        $value =~ s/\\"/"/g;
1684
        $value =~ s/\\\\/\\/g;
1685
    }
1686
    return $value;
1687
}
1688

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

            
1701
sub totp_code {
1702
    my ($key, $counter) = @_;
1703
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1704
    my $hash = hmac_sha1($msg, $key);
1705
    my $offset = ord(substr($hash, -1)) & 0x0f;
1706
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1707
    return sprintf('%06d', $bin % 1_000_000);
1708
}
1709

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

            
1730
sub create_session {
1731
    my $nonce = random_hex(24);
1732
    my $expires = int(time() + 8 * 3600);
1733
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1734
    my $token = "$nonce:$expires:$sig";
1735
    $sessions{$token} = $expires;
1736
    return $token;
1737
}
1738

            
1739
sub is_authenticated {
1740
    my ($headers) = @_;
1741
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1742
    return 0 unless $token;
1743
    my ($nonce, $expires, $sig) = split /:/, $token;
1744
    return 0 unless $nonce && $expires && $sig;
1745
    return 0 if $expires < time();
1746
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1747
    return exists $sessions{$token};
1748
}
1749

            
1750
sub expire_session {
1751
    my ($headers) = @_;
1752
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1753
    delete $sessions{$token} if $token;
1754
}
1755

            
1756
sub cookie_value {
1757
    my ($cookie, $name) = @_;
1758
    for my $part (split /;\s*/, $cookie) {
1759
        my ($k, $v) = split /=/, $part, 2;
1760
        return $v if defined $k && $k eq $name;
1761
    }
1762
    return '';
1763
}
1764

            
1765
sub send_json {
1766
    my ($client, $status, $payload, $extra_headers) = @_;
1767
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1768
}
1769

            
Xdev Host Manager authored a week ago
1770
sub send_json_raw {
1771
    my ($client, $status, $json_body, $extra_headers) = @_;
1772
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1773
}
1774

            
Xdev Host Manager authored a week ago
1775
sub send_html {
1776
    my ($client, $status, $html) = @_;
1777
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1778
}
1779

            
1780
sub send_text {
1781
    my ($client, $status, $text) = @_;
1782
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1783
}
1784

            
1785
sub send_download {
1786
    my ($client, $status, $content, $type, $filename) = @_;
1787
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1788
}
1789

            
1790
sub send_file {
1791
    my ($client, $path, $type, $filename) = @_;
1792
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1793
    return send_download($client, 200, read_file($path), $type, $filename);
1794
}
1795

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

            
1809
sub read_file {
1810
    my ($path) = @_;
1811
    open my $fh, '<', $path or die "Cannot read $path: $!";
1812
    local $/;
1813
    return <$fh>;
1814
}
1815

            
1816
sub write_file {
1817
    my ($path, $content) = @_;
1818
    open my $fh, '>', $path or die "Cannot write $path: $!";
1819
    print {$fh} $content;
1820
    close $fh or die "Cannot close $path: $!";
1821
}
1822

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

            
Bogdan Timofte authored 4 days ago
1834
my $db_handle;
Bogdan Timofte authored 4 days ago
1835
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1836

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

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

            
Bogdan Timofte authored 4 days ago
2117
sub seed_database {
2118
    my ($dbh) = @_;
2119
    seed_default_workers($dbh);
2120

            
2121
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
2122
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
2123
        normalize_registry_policy($registry);
2124
        with_transaction($dbh, sub {
2125
            import_registry_to_db($dbh, $registry, 0);
2126
        });
2127
    }
2128

            
2129
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
2130
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
2131
        with_transaction($dbh, sub {
2132
            import_work_orders_to_db($dbh, $orders);
2133
        });
2134
    }
2135

            
2136
    seed_mdns_observations_from_yaml($dbh);
2137
}
2138

            
2139
sub with_transaction {
2140
    my ($dbh, $code) = @_;
2141
    return $code->() unless $dbh->{AutoCommit};
2142
    $dbh->begin_work;
2143
    my $ok = eval {
2144
        $code->();
2145
        1;
2146
    };
2147
    if (!$ok) {
2148
        my $err = $@ || 'transaction failed';
2149
        eval { $dbh->rollback };
2150
        die $err;
2151
    }
2152
    $dbh->commit;
2153
}
2154

            
2155
sub db_scalar {
2156
    my ($dbh, $sql, @bind) = @_;
2157
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
2158
    return $value || 0;
2159
}
2160

            
2161
sub legacy_document_text {
2162
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
2163
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
2164
    return $row->{content} if $row && defined $row->{content};
2165
    return read_file($seed_path) if -f $seed_path;
2166
    return $default_text;
2167
}
2168

            
2169
sub load_registry_from_db {
2170
    my $dbh = dbh();
2171
    my $registry = {
2172
        version => 1,
2173
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
2174
        policy => {},
2175
        hosts => [],
2176
    };
Bogdan Timofte authored 4 days ago
2177

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

            
2196
    return $registry;
Bogdan Timofte authored 4 days ago
2197
}
2198

            
Bogdan Timofte authored 4 days ago
2199
sub save_registry_to_db {
2200
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
2201
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
2202
    with_transaction($dbh, sub {
2203
        import_registry_to_db($dbh, $registry, 1);
2204
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2205
    });
2206
}
2207

            
2208
sub import_registry_to_db {
2209
    my ($dbh, $registry, $retire_missing) = @_;
2210
    my %seen;
2211
    for my $host (@{ $registry->{hosts} || [] }) {
2212
        my $fqdn = upsert_host_to_db($dbh, $host);
2213
        $seen{$fqdn} = 1 if $fqdn;
2214
    }
2215

            
2216
    return unless $retire_missing;
2217
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2218
    $sth->execute('retired');
2219
    while (my ($fqdn) = $sth->fetchrow_array) {
2220
        next if $seen{$fqdn};
2221
        retire_host_in_db($dbh, $fqdn);
2222
    }
2223
}
2224

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

            
Bogdan Timofte authored 4 days ago
2236
    $dbh->do(
Bogdan Timofte authored 4 days ago
2237
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2238
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2239
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2240
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2241
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2242
        undef,
Bogdan Timofte authored 4 days ago
2243
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2244
    );
2245

            
2246
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2247
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2248
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2249
    return $fqdn;
2250
}
2251

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

            
2265
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2266
    $sth->execute($fqdn);
2267
    while (my ($value) = $sth->fetchrow_array) {
2268
        next if $active{$value};
2269
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2270
    }
2271
}
2272

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

            
2303
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2304
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2305
}
2306

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

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

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

            
2367
sub retire_host_in_db {
2368
    my ($dbh, $fqdn) = @_;
2369
    my $now = iso_now();
2370
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2371
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2372
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2373
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2374
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2375
}
2376

            
Bogdan Timofte authored 4 days ago
2377
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2378
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2379
    my @names;
Bogdan Timofte authored 4 days ago
2380
    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");
2381
    $aliases->execute($fqdn);
2382
    while (my ($name) = $aliases->fetchrow_array) {
2383
        push @names, $name;
2384
    }
Bogdan Timofte authored 4 days ago
2385
    return unique_preserve(@names);
2386
}
2387

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

            
2399
sub active_values_for_host {
2400
    my ($dbh, $table, $column, $fqdn) = @_;
2401
    my @values;
2402
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2403
    $sth->execute($fqdn);
2404
    while (my ($value) = $sth->fetchrow_array) {
2405
        push @values, $value;
2406
    }
2407
    return @values;
2408
}
2409

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

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

            
2442
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2443
        $actions->execute($row->{id});
2444
        while (my $action = $actions->fetchrow_hashref) {
2445
            my %copy = ( type => $action->{type} );
2446
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2447
            $copy{name} = $action->{name} if length($action->{name} || '');
2448
            push @{ $wo->{actions} }, \%copy;
2449
        }
2450

            
2451
        push @{ $orders->{work_orders} }, $wo;
2452
    }
2453
    return $orders;
2454
}
2455

            
2456
sub save_work_orders_to_db {
2457
    my ($orders) = @_;
2458
    my $dbh = dbh();
2459
    with_transaction($dbh, sub {
2460
        import_work_orders_to_db($dbh, $orders);
2461
    });
2462
}
2463

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
2599
sub fqdn_for_legacy_id {
2600
    my ($dbh, $legacy_id) = @_;
2601
    return '' unless length($legacy_id || '');
2602
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2603
    return $fqdn || '';
2604
}
2605

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

            
2621
sub legacy_id_from_fqdn {
2622
    my ($fqdn) = @_;
2623
    $fqdn = normalize_dns_name($fqdn);
2624
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2625
    $fqdn =~ s/\..*\z//;
2626
    return clean_id($fqdn);
2627
}
2628

            
2629
sub normalize_dns_name {
2630
    my ($name) = @_;
2631
    $name = lc clean_scalar($name || '');
2632
    $name =~ s/\.\z//;
2633
    return $name;
2634
}
2635

            
2636
sub name_is_vhost {
2637
    my ($name) = @_;
2638
    $name = normalize_dns_name($name);
2639
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2640
}
2641

            
2642
sub vhost_service_name {
2643
    my ($name) = @_;
2644
    $name = normalize_dns_name($name);
2645
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2646
    return '';
2647
}
2648

            
2649
sub short_alias_for_fqdn {
2650
    my ($name) = @_;
2651
    $name = normalize_dns_name($name);
2652
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2653
    return '';
2654
}
2655

            
Bogdan Timofte authored 4 days ago
2656
sub normalize_registry_policy {
2657
    my ($registry) = @_;
2658
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2659
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2660
    $registry->{policy}{runtime_database} = $opt{db};
2661
}
2662

            
2663
sub default_hosts_yaml {
2664
    return <<'YAML';
2665
version: 1
2666
updated_at: ""
2667
policy:
Bogdan Timofte authored 4 days ago
2668
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2669
hosts:
2670
YAML
2671
}
2672

            
2673
sub default_work_orders_yaml {
2674
    return <<'YAML';
2675
version: 1
2676
work_orders:
2677
YAML
2678
}
2679

            
2680
sub ensure_parent_dir {
2681
    my ($path) = @_;
2682
    my $dir = dirname($path);
2683
    make_path($dir) unless -d $dir;
2684
}
2685

            
Xdev Host Manager authored a week ago
2686
sub url_decode {
2687
    my ($value) = @_;
2688
    $value = '' unless defined $value;
2689
    $value =~ tr/+/ /;
2690
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2691
    return $value;
2692
}
2693

            
2694
sub random_hex {
2695
    my ($bytes) = @_;
2696
    if (open my $fh, '<:raw', '/dev/urandom') {
2697
        read($fh, my $raw, $bytes);
2698
        close $fh;
2699
        return unpack('H*', $raw);
2700
    }
2701
    return sha256_hex(rand() . time() . $$);
2702
}
2703

            
2704
sub iso_now {
2705
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2706
}
2707

            
Bogdan Timofte authored 6 days ago
2708
sub build_info {
2709
    my %info = (
2710
        revision => '',
2711
        branch => '',
2712
        built_at => '',
2713
        deployed_at => '',
2714
        dirty => '',
2715
    );
2716

            
2717
    if ($ENV{HOST_MANAGER_BUILD}) {
2718
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2719
        return \%info;
2720
    }
2721

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

            
2731
    my $revision = git_value('rev-parse --short=12 HEAD');
2732
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2733
    $info{revision} = $revision if $revision;
2734
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2735
    return \%info;
2736
}
2737

            
2738
sub git_value {
2739
    my ($args) = @_;
2740
    return '' unless -d "$project_dir/.git";
2741
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2742
    my $value = <$fh> || '';
2743
    close $fh;
2744
    chomp $value;
2745
    return clean_scalar($value);
2746
}
2747

            
2748
sub build_label {
2749
    my $info = build_info();
2750
    my $revision = $info->{revision} || 'unknown';
2751
    my $branch = $info->{branch} || '';
2752
    $branch = '' if $branch eq 'HEAD';
2753
    my $label = $branch ? "$branch $revision" : $revision;
2754
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2755
    return $label;
2756
}
2757

            
2758
sub build_title {
2759
    my $info = build_info();
2760
    my $label = build_label();
2761
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2762
    return $stamp ? "$label deployed $stamp" : $label;
2763
}
2764

            
Bogdan Timofte authored 4 days ago
2765
sub build_revision {
2766
    my $info = build_info();
2767
    return $info->{revision} || 'unknown';
2768
}
2769

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

            
Bogdan Timofte authored 6 days ago
2785
sub html_escape {
2786
    my ($value) = @_;
2787
    $value = '' unless defined $value;
2788
    $value =~ s/&/&amp;/g;
2789
    $value =~ s/</&lt;/g;
2790
    $value =~ s/>/&gt;/g;
2791
    $value =~ s/"/&quot;/g;
2792
    $value =~ s/'/&#039;/g;
2793
    return $value;
2794
}
2795

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

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

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

            
Xdev Host Manager authored a week ago
3086
  <!-- ── Login screen ── -->
3087
  <div id="login-screen">
3088
    <div class="login-card">
3089
      <div class="brand">
3090
        <div class="icon">
Xdev Host Manager authored a week ago
3091
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
3092
            <rect x="16" y="10" width="32" height="44" rx="4"/>
3093
            <rect x="21" y="16" width="22" height="8" rx="2"/>
3094
            <rect x="21" y="28" width="22" height="8" rx="2"/>
3095
            <rect x="21" y="40" width="22" height="8" rx="2"/>
3096
            <path d="M26 20h8M26 32h8M26 44h8"/>
3097
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
3098
          </svg>
3099
        </div>
Xdev Host Manager authored a week ago
3100
        <h1>Madagascar Local Authority</h1>
3101
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
3102
      </div>
Bogdan Timofte authored 4 days ago
3103
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
3104
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
3105
        <div class="pm-helper-fields" aria-hidden="true">
3106
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
3107
          <input type="hidden" id="otp-hidden" name="otp">
3108
        </div>
Xdev Host Manager authored a week ago
3109
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
3110
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
3111
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
3112
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
3113
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
3114
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
3115
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
3116
        </div>
3117
      </form>
3118
    </div>
3119
  </div>
3120

            
3121
  <!-- ── App (shown after login) ── -->
3122
  <div id="app">
3123
    <header>
Xdev Host Manager authored a week ago
3124
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
3125
      <nav aria-label="Sections">
3126
        <a href="/overview" data-page-link="overview">Overview</a>
3127
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
3128
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
3129
        <a href="/dns" data-page-link="dns">DNS</a>
3130
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
3131
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
3132
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
3133
      </nav>
Xdev Host Manager authored a week ago
3134
      <div class="header-right">
3135
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
3136
        <span id="message" class="muted"></span>
3137
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
3138
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
3139
      </div>
Xdev Host Manager authored a week ago
3140
    </header>
3141
    <main>
Bogdan Timofte authored 5 days ago
3142
      <section class="page" id="page-overview" data-page="overview">
3143
        <section class="panel">
3144
          <div class="panel-head">
3145
            <h2>Overview</h2>
3146
            <div class="stats" id="stats"></div>
3147
          </div>
3148
          <div class="problems" id="problems"></div>
3149
        </section>
Xdev Host Manager authored a week ago
3150
      </section>
3151

            
Bogdan Timofte authored 5 days ago
3152
      <section class="page" id="page-hosts" data-page="hosts" hidden>
3153
        <section class="panel">
3154
          <div class="panel-head">
3155
            <h2>Hosts</h2>
3156
            <div class="host-tools">
3157
              <input id="filter" placeholder="filter">
3158
              <button type="button" id="new-host">New host</button>
3159
            </div>
3160
          </div>
3161
          <div class="table-wrap">
3162
            <table>
3163
              <thead>
3164
                <tr>
3165
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
3166
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
3167
                  <th>Names</th>
3168
                  <th style="width: 150px">Roles</th>
3169
                  <th style="width: 110px">Monitoring</th>
3170
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3171
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 5 days ago
3172
                </tr>
3173
              </thead>
3174
              <tbody id="hosts"></tbody>
3175
            </table>
3176
          </div>
3177
        </section>
Xdev Host Manager authored a week ago
3178
      </section>
Xdev Host Manager authored a week ago
3179

            
Bogdan Timofte authored 4 days ago
3180
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
3181
        <section class="panel">
3182
          <div class="panel-head">
3183
            <h2>Vhosts</h2>
3184
            <div class="host-tools">
3185
              <input id="vhost-filter" placeholder="filter">
3186
              <div class="stats" id="vhost-stats"></div>
3187
            </div>
3188
          </div>
Bogdan Timofte authored 4 days ago
3189
          <div class="vhost-inline-editor">
3190
            <input id="vhost-new-name" placeholder="vhost fqdn">
3191
            <select id="vhost-new-host"></select>
3192
            <button type="button" id="vhost-add">Add</button>
3193
          </div>
Bogdan Timofte authored 4 days ago
3194
          <div class="table-wrap">
3195
            <table>
3196
              <thead>
3197
                <tr>
3198
                  <th>Vhost</th>
3199
                  <th style="width: 190px">Host</th>
3200
                  <th style="width: 140px">IP</th>
3201
                  <th style="width: 180px">Derived aliases</th>
3202
                  <th style="width: 120px">Monitoring</th>
3203
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3204
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 4 days ago
3205
                </tr>
3206
              </thead>
3207
              <tbody id="vhosts"></tbody>
3208
            </table>
3209
          </div>
3210
        </section>
3211
      </section>
3212

            
Bogdan Timofte authored 5 days ago
3213
      <section class="page" id="page-dns" data-page="dns" hidden>
3214
        <section class="toolbar">
3215
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3216
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3217
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3218
          <button id="write-tsv">Write local-hosts.tsv</button>
3219
        </section>
Xdev Host Manager authored a week ago
3220
      </section>
3221

            
Bogdan Timofte authored 5 days ago
3222
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3223
        <section class="panel">
3224
          <div class="panel-head">
3225
            <h2>Work Orders</h2>
3226
            <div class="stats" id="wo-stats"></div>
3227
          </div>
3228
          <div class="problems" id="work-orders"></div>
3229
        </section>
Xdev Host Manager authored a week ago
3230
      </section>
3231

            
Bogdan Timofte authored 5 days ago
3232
      <section class="page" id="page-ca" data-page="ca" hidden>
3233
        <section class="panel">
3234
          <div class="panel-head">
3235
            <h2>Local Certificate Authority</h2>
3236
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3237
          </div>
3238
          <div class="problems" id="ca-status"></div>
3239
        </section>
3240
        <section class="panel">
3241
          <div class="panel-head">
3242
            <h2>Issued Certificates</h2>
3243
            <div class="stats" id="ca-certs-summary"></div>
3244
          </div>
3245
          <div class="table-wrap">
3246
            <table>
3247
              <thead>
3248
                <tr>
3249
                  <th style="width: 150px">Name</th>
3250
                  <th>DNS names</th>
3251
                  <th style="width: 210px">Validity</th>
3252
                  <th style="width: 180px">Serial</th>
3253
                  <th>Fingerprint</th>
3254
                  <th style="width: 110px">Download</th>
3255
                </tr>
3256
              </thead>
3257
              <tbody id="ca-certs"></tbody>
3258
            </table>
3259
          </div>
3260
        </section>
Xdev Host Manager authored a week ago
3261
      </section>
Bogdan Timofte authored 4 days ago
3262

            
3263
      <section class="page" id="page-debug" data-page="debug" hidden>
3264
        <section class="panel">
3265
          <div class="panel-head">
3266
            <h2>Database</h2>
3267
            <div class="stats" id="debug-db-stats"></div>
3268
          </div>
3269
          <div class="toolbar">
3270
            <div class="debug-controls">
3271
              <button type="button" id="debug-db-refresh">Refresh</button>
3272
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3273
            </div>
3274
          </div>
Bogdan Timofte authored 4 days ago
3275
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3276
        </section>
3277
        <section class="debug-section">
3278
          <section class="panel">
3279
            <div class="panel-head">
3280
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3281
              <div class="debug-table-head-actions">
3282
                <div class="stats" id="debug-table-stats"></div>
3283
                <div class="debug-table-exports">
3284
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3285
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3286
                </div>
3287
              </div>
Bogdan Timofte authored 4 days ago
3288
            </div>
3289
            <div class="table-wrap" id="debug-table-rows"></div>
3290
          </section>
3291
          <section class="panel">
3292
            <div class="panel-head">
3293
              <h2>Columns</h2>
3294
            </div>
3295
            <div class="table-wrap" id="debug-table-columns"></div>
3296
          </section>
3297
          <section class="panel">
3298
            <div class="panel-head">
3299
              <h2>Indexes</h2>
3300
            </div>
3301
            <div class="table-wrap" id="debug-table-indexes"></div>
3302
          </section>
3303
          <section class="panel">
3304
            <div class="panel-head">
3305
              <h2>Foreign Keys</h2>
3306
            </div>
3307
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3308
          </section>
3309
        </section>
3310
      </section>
Bogdan Timofte authored 5 days ago
3311
    </main>
Xdev Host Manager authored a week ago
3312

            
3313
  </div>
3314

            
Bogdan Timofte authored 4 days ago
3315
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3316
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3317
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3318
  </div>
Bogdan Timofte authored 6 days ago
3319

            
Xdev Host Manager authored a week ago
3320
  <script>
Bogdan Timofte authored 4 days ago
3321
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3322
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3323
    let hostFormBusy = false;
3324
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3325
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3326

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

            
Bogdan Timofte authored 4 days ago
3379
    function isAuthLost(error) {
3380
      return !!(error && error.authLost);
3381
    }
3382

            
3383
    function authLostError(message) {
3384
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3385
      error.authLost = true;
3386
      return error;
3387
    }
3388

            
3389
    function handleAuthLost(message) {
3390
      state.authenticated = false;
3391
      msg('');
3392
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3393
    }
3394

            
Bogdan Timofte authored 4 days ago
3395
    async function ensureAuthenticated(message) {
3396
      if (!state.authenticated) {
3397
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3398
        return false;
3399
      }
3400
      const session = await api('/api/session');
3401
      state.authenticated = session.authenticated;
3402
      if (!state.authenticated) {
3403
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3404
        return false;
3405
      }
3406
      return true;
3407
    }
3408

            
Xdev Host Manager authored a week ago
3409
    async function api(path, options = {}) {
3410
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3411
      let body = {};
3412
      try {
3413
        body = await res.json();
3414
      } catch (_) {
3415
        body = {};
3416
      }
3417
      const errorCode = body.error || '';
3418
      if (!res.ok) {
3419
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3420
          const error = authLostError();
3421
          handleAuthLost(error.message);
3422
          throw error;
3423
        }
3424
        throw new Error(errorCode || res.statusText);
3425
      }
Xdev Host Manager authored a week ago
3426
      return body;
3427
    }
3428

            
Bogdan Timofte authored 5 days ago
3429
    function currentPage() {
3430
      return PAGE_PATHS[window.location.pathname] || 'overview';
3431
    }
3432

            
3433
    function showPage(page, push = false) {
3434
      const target = page || 'overview';
3435
      document.querySelectorAll('[data-page]').forEach(section => {
3436
        section.hidden = section.dataset.page !== target;
3437
      });
3438
      document.querySelectorAll('[data-page-link]').forEach(link => {
3439
        link.classList.toggle('active', link.dataset.pageLink === target);
3440
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3441
      });
3442
      if (push) {
3443
        const href = target === 'overview' ? '/overview' : '/' + target;
3444
        history.pushState({ page: target }, '', href);
3445
      }
Bogdan Timofte authored 4 days ago
3446
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3447
        renderDebugDatabase().catch(e => {
3448
          if (!isAuthLost(e)) msg(e.message);
3449
        });
Bogdan Timofte authored 4 days ago
3450
      }
Bogdan Timofte authored 5 days ago
3451
    }
3452

            
Xdev Host Manager authored a week ago
3453
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3454
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3455
      document.body.classList.remove('is-app');
3456
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3457
      $('app').style.display = 'none';
3458
      $('login-screen').style.display = 'flex';
3459
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3460
      clearOtp();
Xdev Host Manager authored a week ago
3461
    }
3462

            
3463
    function showApp() {
Bogdan Timofte authored 6 days ago
3464
      document.body.classList.remove('is-login');
3465
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3466
      $('login-screen').style.display = 'none';
3467
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3468
      showPage(currentPage());
Xdev Host Manager authored a week ago
3469
    }
3470

            
Xdev Host Manager authored a week ago
3471
    async function refresh() {
3472
      const session = await api('/api/session');
3473
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3474
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3475
      showApp();
Xdev Host Manager authored a week ago
3476
      const data = await api('/api/hosts');
3477
      state.hosts = data.hosts || [];
3478
      state.problems = data.problems || [];
3479
      render(data);
Xdev Host Manager authored a week ago
3480
      await renderCa();
Xdev Host Manager authored a week ago
3481
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3482
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3483
    }
3484

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

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

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

            
3498
      renderHosts();
Bogdan Timofte authored 4 days ago
3499
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3500
      renderVhosts();
Xdev Host Manager authored a week ago
3501
    }
3502

            
Xdev Host Manager authored a week ago
3503
    async function renderCa() {
3504
      try {
3505
        const status = await api('/api/ca/status');
3506
        if (!status.initialized) {
3507
          $('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
3508
          $('ca-certs-summary').innerHTML = '';
3509
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3510
          return;
3511
        }
3512
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
3513
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3514
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3515
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3516
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3517
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3518
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3519
            <div>
3520
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3521
              <span>${certs.length} issued certificate(s)</span>
3522
            </div>
Xdev Host Manager authored a week ago
3523
          </div>`;
Bogdan Timofte authored 5 days ago
3524
        $('ca-certs-summary').innerHTML = [
3525
          ['issued', certs.length],
3526
          ['expiring', certs.filter(cert => {
3527
            const days = daysUntil(cert.not_after);
3528
            return days !== null && days >= 0 && days <= 30;
3529
          }).length],
3530
          ['expired', certs.filter(cert => {
3531
            const days = daysUntil(cert.not_after);
3532
            return days !== null && days < 0;
3533
          }).length],
3534
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3535
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3536
          const days = daysUntil(cert.not_after);
3537
          const dnsNames = cert.dns_names || [];
3538
          const dnsHtml = dnsNames.length
3539
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3540
            : '<span class="muted">No DNS SANs reported.</span>';
3541
          return `<tr>
3542
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3543
            <td>${dnsHtml}</td>
3544
            <td>
3545
              <div class="ca-detail">
3546
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3547
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3548
              </div>
3549
            </td>
3550
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3551
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
3552
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
3553
          </tr>`;
3554
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3555
      } catch (e) {
Bogdan Timofte authored 4 days ago
3556
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3557
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3558
        $('ca-certs-summary').innerHTML = '';
3559
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3560
      }
3561
    }
3562

            
Bogdan Timofte authored 5 days ago
3563
    function daysUntil(dateText) {
3564
      const time = Date.parse(dateText || '');
3565
      if (!Number.isFinite(time)) return null;
3566
      return Math.ceil((time - Date.now()) / 86400000);
3567
    }
3568

            
3569
    function certStatusClass(days) {
3570
      if (days === null) return '';
3571
      if (days < 0) return 'bad';
3572
      if (days <= 30) return 'warn';
3573
      return 'ok';
3574
    }
3575

            
3576
    function certStatusLabel(days) {
3577
      if (days === null) return 'validity unknown';
3578
      if (days < 0) return 'expired';
3579
      if (days === 0) return 'expires today';
3580
      return `${days}d remaining`;
3581
    }
3582

            
Xdev Host Manager authored a week ago
3583
    async function renderWorkOrders() {
3584
      try {
3585
        const data = await api('/api/work-orders');
3586
        state.workOrders = data.work_orders || [];
3587
        $('wo-stats').innerHTML = [
3588
          ['pending', data.counts.pending],
3589
          ['total', data.counts.work_orders],
3590
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3591

            
3592
        if (!state.workOrders.length) {
3593
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3594
          return;
3595
        }
3596

            
3597
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3598
          const checklist = wo.checklist || [];
3599
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3600
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3601
          const checklistHtml = checklist.map(item => {
3602
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3603
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3604
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3605
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3606
            </label>`;
3607
          }).join('');
Xdev Host Manager authored a week ago
3608
          const actions = (wo.actions || []).map(a => {
3609
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3610
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3611
          }).join('');
3612
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3613
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3614
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3615
            : '';
Bogdan Timofte authored 6 days ago
3616
          return `<div class="problem work-order-card">
3617
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3618
              <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
3619
              ${button}
3620
            </div>
Bogdan Timofte authored 6 days ago
3621
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3622
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3623
            <div class="work-order-checklist">${checklistHtml}</div>
3624
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3625
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3626
          </div>`;
3627
        }).join('');
Xdev Host Manager authored a week ago
3628
        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
3629
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3630
      } catch (e) {
Bogdan Timofte authored 4 days ago
3631
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3632
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3633
      }
3634
    }
3635

            
Bogdan Timofte authored 4 days ago
3636
    async function renderDebugDatabase() {
3637
      if (!state.authenticated) return;
3638
      const data = await api('/api/debug/database/tables');
3639
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3640
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3641
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3642
      $('debug-db-stats').innerHTML = [
3643
        ['tables', data.counts ? data.counts.tables : tables.length],
3644
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3645
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3646
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3647
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3648
      if (selected) {
3649
        await renderDebugTable(selected);
3650
      } else {
3651
        clearDebugTable();
3652
      }
3653
    }
3654

            
Bogdan Timofte authored 4 days ago
3655
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3656
      $('debug-db-tables').innerHTML = tables.length
3657
        ? tables.map(table => {
3658
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3659
            const ref = debugTableReference(database, table.name);
3660
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3661
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3662
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3663
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3664
              </button>
Bogdan Timofte authored 4 days ago
3665
              <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
3666
            </div>`;
Bogdan Timofte authored 4 days ago
3667
          }).join('')
3668
        : '<div class="ca-empty muted">No database tables found.</div>';
3669
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3670
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3671
          if (!isAuthLost(e)) msg(e.message);
3672
        }));
3673
      });
Bogdan Timofte authored 4 days ago
3674
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3675
        button.addEventListener('click', async () => {
3676
          try {
3677
            await copyText(button.dataset.debugTableRef || '');
3678
            msg('table reference copied');
3679
          } catch (e) {
3680
            msg('copy failed');
3681
          }
3682
        });
3683
      });
3684
    }
3685

            
3686
    function debugTableReference(database, tableName) {
3687
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3688
    }
3689

            
3690
    async function selectDebugTable(tableName) {
3691
      state.debugTable = tableName || '';
3692
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3693
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3694
        const card = button.closest('.debug-table-card');
3695
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3696
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3697
      });
3698
      if (state.debugTable) await renderDebugTable(state.debugTable);
3699
    }
3700

            
3701
    function clearDebugTable() {
3702
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3703
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3704
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3705
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3706
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3707
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3708
    }
3709

            
3710
    async function renderDebugTable(tableName) {
3711
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3712
      if (data.error) throw new Error(data.error);
3713
      $('debug-table-stats').innerHTML = [
3714
        ['table', data.table || tableName],
3715
        ['rows', data.row_count || 0],
3716
        ['shown', (data.rows || []).length],
3717
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3718
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3719
      renderDebugRows(data);
3720
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3721
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3722
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3723
    }
3724

            
Bogdan Timofte authored 4 days ago
3725
    function updateDebugExportLinks(tableName) {
3726
      const encoded = encodeURIComponent(tableName || '');
3727
      [
3728
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3729
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3730
      ].forEach(([id, href]) => {
3731
        const link = $(id);
3732
        const enabled = !!tableName;
3733
        link.href = enabled ? href : '#';
3734
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3735
      });
3736
    }
3737

            
Bogdan Timofte authored 4 days ago
3738
    function renderDebugRows(data) {
3739
      const rows = data.rows || [];
3740
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3741
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3742
    }
3743

            
3744
    function renderDebugObjectTable(rows, preferredKeys) {
3745
      const keys = preferredKeys && preferredKeys.length
3746
        ? preferredKeys
3747
        : Array.from(rows.reduce((set, row) => {
3748
            Object.keys(row || {}).forEach(key => set.add(key));
3749
            return set;
3750
          }, new Set()));
3751
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3752
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3753
      const body = rows.length
3754
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3755
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3756
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3757
    }
3758

            
3759
    function debugCell(value) {
3760
      if (value === null || value === undefined) return 'NULL';
3761
      if (Array.isArray(value)) return value.join(', ');
3762
      if (typeof value === 'object') return JSON.stringify(value);
3763
      return String(value);
3764
    }
3765

            
Xdev Host Manager authored a week ago
3766
    async function updateWorkOrderChecklist(id, itemId, checked) {
3767
      try {
3768
        await api('/api/work-orders/checklist', {
3769
          method: 'POST',
3770
          headers: { 'Content-Type': 'application/json' },
3771
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3772
        });
3773
        msg('work order updated');
3774
        await refresh();
Bogdan Timofte authored 4 days ago
3775
      } catch (e) {
3776
        if (isAuthLost(e)) return;
3777
        msg(e.message);
3778
        await refresh().catch(refreshError => {
3779
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3780
        });
3781
      }
Xdev Host Manager authored a week ago
3782
    }
3783

            
Xdev Host Manager authored a week ago
3784
    async function confirmWorkOrder(id) {
3785
      const typed = prompt(`Type ${id} to confirm this work order`);
3786
      if (typed !== id) return;
3787
      try {
3788
        await api('/api/work-orders/confirm', {
3789
          method: 'POST',
3790
          headers: { 'Content-Type': 'application/json' },
3791
          body: JSON.stringify({ id, confirm: typed })
3792
        });
3793
        msg('work order confirmed; local-hosts.tsv written');
3794
        await refresh();
Bogdan Timofte authored 4 days ago
3795
      } catch (e) {
3796
        if (isAuthLost(e)) return;
3797
        msg(e.message);
3798
      }
Xdev Host Manager authored a week ago
3799
    }
3800

            
Xdev Host Manager authored a week ago
3801
    function renderHosts() {
3802
      const filter = $('filter').value.toLowerCase();
3803
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
3804
        .slice()
3805
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3806
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3807
        .map(h => {
3808
          const problems = state.problems.filter(p => p.host_id === h.id);
3809
          const cls = problems.length ? 'warn' : 'ok';
3810
          return `<tr data-id="${escapeHtml(h.id)}">
Bogdan Timofte authored 4 days ago
3811
            <td>${escapeHtml(h.id)}</td>
Bogdan Timofte authored 4 days ago
3812
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 5 days ago
3813
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3814
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3815
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3816
            <td>${escapeHtml(h.status || '')}</td>
Bogdan Timofte authored 4 days ago
3817
            <td><button type="button" data-edit="${escapeHtml(h.id)}">Edit</button></td>
Xdev Host Manager authored a week ago
3818
          </tr>`;
3819
        }).join('');
Bogdan Timofte authored 4 days ago
3820
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3821
        editHost(button.dataset.edit).catch(e => {
3822
          if (!isAuthLost(e)) msg(e.message);
3823
        });
3824
      }));
Bogdan Timofte authored 4 days ago
3825
      mountHostEditor();
Xdev Host Manager authored a week ago
3826
    }
3827

            
Bogdan Timofte authored 5 days ago
3828
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3829
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3830
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3831
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3832
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3833
    }
3834

            
3835
    function vhostRows() {
3836
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3837
        vhost,
3838
        host_id: host.id || '',
3839
        host_fqdn: host.fqdn || '',
3840
        ip: host.ip || '',
3841
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3842
        monitoring: host.monitoring || '',
3843
        status: host.status || '',
3844
      })));
3845
    }
3846

            
3847
    function renderVhosts() {
3848
      const input = $('vhost-filter');
3849
      const filter = input ? input.value.toLowerCase() : '';
3850
      const rows = vhostRows()
3851
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3852
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3853
      $('vhost-stats').innerHTML = [
3854
        ['shown', rows.length],
3855
        ['total', vhostRows().length],
3856
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3857
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3858
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
Bogdan Timofte authored 4 days ago
3859
        <td>
3860
          <div class="vhost-host">
3861
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3862
              ${renderVhostHostOptions(row.host_fqdn)}
3863
            </select>
3864
          </div>
3865
        </td>
Bogdan Timofte authored 4 days ago
3866
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
3867
        <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
3868
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3869
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
3870
        <td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
3871
      </tr>`).join('') : '<tr><td colspan="7" class="muted">No vhosts.</td></tr>';
3872
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
3873
        select.addEventListener('change', () => {
3874
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
3875
            if (!isAuthLost(e)) msg(e.message);
3876
            select.value = select.dataset.currentHost || '';
3877
          });
Bogdan Timofte authored 4 days ago
3878
        });
Bogdan Timofte authored 4 days ago
3879
      });
Bogdan Timofte authored 4 days ago
3880
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
3881
        button.addEventListener('click', () => {
3882
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
3883
            if (!isAuthLost(e)) msg(e.message);
3884
          });
3885
        });
3886
      });
3887
    }
3888

            
3889
    function renderVhostEditor() {
3890
      const select = $('vhost-new-host');
3891
      const current = select.value || '';
3892
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3893
    }
3894

            
3895
    function renderVhostHostOptions(selectedHostFqdn) {
3896
      return state.hosts
3897
        .slice()
3898
        .filter(host => (host.status || '') !== 'retired')
3899
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3900
        .map(host => {
3901
          const fqdn = host.fqdn || '';
3902
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
3903
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
3904
        }).join('');
Bogdan Timofte authored 4 days ago
3905
    }
3906

            
3907
    function shortAliasForFqdn(name) {
3908
      const suffix = '.madagascar.xdev.ro';
3909
      name = String(name || '').toLowerCase();
3910
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 5 days ago
3911
    }
3912

            
Bogdan Timofte authored 4 days ago
3913
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
3914
      const vhost = select.dataset.vhostSelect || '';
3915
      const fromHost = select.dataset.currentHost || '';
3916
      const toHost = select.value || '';
3917
      if (!vhost || !toHost || toHost === fromHost) return;
3918
      select.disabled = true;
3919
      try {
3920
        await api('/api/vhosts/reassign', {
3921
          method: 'POST',
3922
          headers: { 'Content-Type': 'application/json' },
3923
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3924
        });
3925
        msg(`vhost ${vhost} moved`);
3926
        await refresh();
3927
      } finally {
3928
        select.disabled = false;
3929
      }
3930
    }
3931

            
Bogdan Timofte authored 4 days ago
3932
    async function addVhostInline() {
3933
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
3934
      const nameInput = $('vhost-new-name');
3935
      const hostSelect = $('vhost-new-host');
3936
      const vhost = (nameInput.value || '').trim().toLowerCase();
3937
      const hostFqdn = hostSelect.value || '';
3938
      if (!vhost || !hostFqdn) return;
3939
      $('vhost-add').disabled = true;
3940
      nameInput.disabled = true;
3941
      hostSelect.disabled = true;
3942
      try {
3943
        await api('/api/vhosts/upsert', {
3944
          method: 'POST',
3945
          headers: { 'Content-Type': 'application/json' },
3946
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
3947
        });
3948
        nameInput.value = '';
3949
        msg(`vhost ${vhost} saved`);
3950
        await refresh();
3951
      } finally {
3952
        $('vhost-add').disabled = false;
3953
        nameInput.disabled = false;
3954
        hostSelect.disabled = false;
3955
      }
3956
    }
3957

            
3958
    async function deleteVhostInline(vhost) {
3959
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
3960
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
3961
      await api('/api/vhosts/delete', {
3962
        method: 'POST',
3963
        headers: { 'Content-Type': 'application/json' },
3964
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
3965
      });
3966
      msg(`vhost ${vhost} deleted`);
3967
      await refresh();
3968
    }
3969

            
Bogdan Timofte authored 4 days ago
3970
    async function editHost(id) {
3971
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
3972
      const host = state.hosts.find(h => h.id === id);
3973
      if (!host) return;
Bogdan Timofte authored 4 days ago
3974
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
3975
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3976
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3977
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
3978
      hostField('roles').value = (host.roles || []).join(' ');
3979
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
3980
      activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
3981
    }
3982

            
Bogdan Timofte authored 4 days ago
3983
    async function newHost() {
3984
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
3985
      if (!canSwitchHostEditor('__new__')) return;
3986
      resetHostForm(true);
3987
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
3988
    }
3989

            
Bogdan Timofte authored 4 days ago
3990
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
3991
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
3992
      hostEditorTarget = target || '';
3993
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
3994
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
3995
      renderHosts();
3996
      hostFormSnapshot = hostFormState();
3997
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
3998
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
3999
    }
4000

            
Bogdan Timofte authored 4 days ago
4001
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4002
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4003
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4004
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4005
      hostField('status').value = 'active';
4006
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4007
      hostFormSnapshot = force ? '' : hostFormState();
4008
    }
4009

            
4010
    function closeHostForm(force = false) {
4011
      if (hostFormBusy && !force) return;
4012
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4013
      hostEditorTarget = '';
4014
      hostFormMode = 'new';
4015
      hostFormSnapshot = '';
4016
      clearHostFormMessage();
4017
      syncHostFormActions();
4018
      mountHostEditor();
4019
    }
4020

            
4021
    function canSwitchHostEditor(target) {
4022
      if (hostFormBusy) return false;
4023
      if (!hostEditorTarget) return true;
4024
      if (!hostFormDirty()) return true;
4025
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4026
      return confirm('Discard unsaved host changes?');
4027
    }
4028

            
4029
    function mountHostEditor() {
4030
      hostEditorRow.remove();
4031
      if (!hostEditorTarget) {
4032
        hostFormShell.hidden = true;
4033
        return;
4034
      }
4035
      hostEditorCell.colSpan = 7;
4036
      const tbody = $('hosts');
4037
      if (!tbody) return;
4038
      if (hostEditorTarget === '__new__') {
4039
        tbody.prepend(hostEditorRow);
4040
      } else {
4041
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4042
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4043
        if (targetRow) targetRow.after(hostEditorRow);
4044
        else tbody.prepend(hostEditorRow);
4045
      }
4046
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4047
    }
4048

            
4049
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4050
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4051
    }
4052

            
4053
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4054
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4055
    }
4056

            
4057
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4058
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4059
    }
4060

            
4061
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4062
      hostFormBusy = !!busy;
4063
      syncHostFormActions();
4064
    }
4065

            
4066
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4067
      saveHostButton.disabled = hostFormBusy;
4068
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4069
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4070
    }
4071

            
4072
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4073
      hostFormMessage.textContent = text || '';
4074
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4075
    }
4076

            
4077
    function clearHostFormMessage() {
4078
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4079
    }
4080

            
4081
    function formObject(form) {
4082
      return Object.fromEntries(new FormData(form).entries());
4083
    }
4084

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

            
Bogdan Timofte authored 6 days ago
4090
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4091

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

            
4097
    if (loginAccount) {
4098
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4099
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4100
      loginAccount.addEventListener('input', () => {
4101
        const value = (loginAccount.value || '').trim();
4102
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4103
      });
4104
    }
4105

            
Xdev Host Manager authored a week ago
4106
    function setOtpDigit(idx, value) {
4107
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
4108
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4109
      otpDigits[idx].classList.toggle('filled', !!digit);
4110
    }
4111

            
Bogdan Timofte authored 4 days ago
4112
    // Move focus to the next empty box: forward from idx, then wrapping to the
4113
    // start. This lets out-of-order entry continue (e.g. after the last box,
4114
    // jump back to the first still-empty box). Stays put when all boxes are full.
4115
    function advanceFocus(idx) {
4116
      for (let i = idx + 1; i < otpDigits.length; i++) {
4117
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4118
      }
4119
      for (let i = 0; i <= idx; i++) {
4120
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4121
      }
4122
    }
4123

            
Bogdan Timofte authored 5 days ago
4124
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4125
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4126
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
4127
      const digits = (text || '').replace(/\D/g, '').split('');
4128
      if (!digits.length) return;
4129
      let last = startIdx;
4130
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4131
        last = startIdx + i;
4132
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4133
      }
Bogdan Timofte authored 5 days ago
4134
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4135
      advanceFocus(last);
Xdev Host Manager authored a week ago
4136
      maybeSubmitOtp();
4137
    }
4138

            
Bogdan Timofte authored 5 days ago
4139
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4140
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4141
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4142
    function maybeSubmitOtp() {
4143
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4144
    }
4145
    function clearOtp() {
Bogdan Timofte authored 5 days ago
4146
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4147
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4148
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4149
      // an unknown operator, so Safari's autofill anchor on the username stays.
4150
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4151
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4152
    }
4153

            
Bogdan Timofte authored 5 days ago
4154
    otpDigits.forEach((input, idx) => {
4155
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4156
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4157
        // A single box may receive several digits at once (autofill / typing fast).
4158
        if (input.value.replace(/\D/g, '').length > 1) {
4159
          fillOtp(input.value, idx);
4160
          return;
4161
        }
4162
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
4163
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4164
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
4165
        maybeSubmitOtp();
4166
      });
Bogdan Timofte authored 5 days ago
4167

            
4168
      input.addEventListener('paste', (e) => {
4169
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4170
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4171
        const text = (e.clipboardData || window.clipboardData).getData('text');
4172
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
4173
      });
Bogdan Timofte authored 5 days ago
4174

            
4175
      input.addEventListener('keydown', (e) => {
4176
        if (e.key === 'Backspace') {
4177
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4178
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
4179
          if (input.value) { setOtpDigit(idx, ''); }
4180
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4181
          syncOtpFields();
4182
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4183
          e.preventDefault();
4184
          otpDigits[idx - 1].focus();
4185
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4186
          e.preventDefault();
4187
          otpDigits[idx + 1].focus();
4188
        }
4189
      });
4190
    });
4191

            
Bogdan Timofte authored 4 days ago
4192
    // Focus the first OTP box only for a returning operator (username known).
4193
    // For an unknown operator, leave focus on the username field so Safari can
4194
    // present its OTP autofill anchored there without being dismissed by a focus
4195
    // change (pbx-admin pattern).
4196
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4197
    else if (loginAccount) loginAccount.focus();
4198
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4199

            
Bogdan Timofte authored 5 days ago
4200
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4201
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4202
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4203
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4204
        showPage(link.dataset.pageLink, true);
4205
      });
4206
    });
4207

            
Bogdan Timofte authored 4 days ago
4208
    window.addEventListener('popstate', () => {
4209
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4210
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4211
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4212
    });
Bogdan Timofte authored 5 days ago
4213

            
Bogdan Timofte authored 4 days ago
4214
    async function copyText(text) {
4215
      if (navigator.clipboard && window.isSecureContext) {
4216
        await navigator.clipboard.writeText(text);
4217
        return;
4218
      }
4219
      const input = document.createElement('textarea');
4220
      input.value = text;
4221
      input.setAttribute('readonly', '');
4222
      input.style.position = 'fixed';
4223
      input.style.left = '-10000px';
4224
      document.body.appendChild(input);
4225
      input.select();
4226
      document.execCommand('copy');
4227
      document.body.removeChild(input);
4228
    }
4229

            
4230
    $('copy-build').addEventListener('click', async () => {
4231
      try {
4232
        await copyText($('copy-build').dataset.buildDetails || '');
4233
        if (state.authenticated) msg('build details copied');
4234
      } catch (e) {
4235
        if (state.authenticated) msg('copy failed');
4236
      }
4237
    });
4238

            
Xdev Host Manager authored a week ago
4239
    $('login-form').addEventListener('submit', async (event) => {
4240
      event.preventDefault();
Bogdan Timofte authored 5 days ago
4241
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4242
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4243
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4244
      try {
Xdev Host Manager authored a week ago
4245
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4246
        await refresh();
Xdev Host Manager authored a week ago
4247
      } catch (e) {
4248
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4249
      } finally {
Xdev Host Manager authored a week ago
4250
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4251
      }
Xdev Host Manager authored a week ago
4252
    });
4253

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

            
Bogdan Timofte authored 4 days ago
4259
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4260
      if (!isAuthLost(e)) msg(e.message);
4261
    }));
Xdev Host Manager authored a week ago
4262
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4263
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4264
    $('vhost-add').addEventListener('click', () => {
4265
      addVhostInline().catch(e => {
4266
        if (!isAuthLost(e)) msg(e.message);
4267
      });
4268
    });
4269
    $('vhost-new-name').addEventListener('keydown', (event) => {
4270
      if (event.key !== 'Enter') return;
4271
      event.preventDefault();
4272
      addVhostInline().catch(e => {
4273
        if (!isAuthLost(e)) msg(e.message);
4274
      });
4275
    });
Bogdan Timofte authored 4 days ago
4276
    $('new-host').addEventListener('click', () => {
4277
      newHost().catch(e => {
4278
        if (!isAuthLost(e)) msg(e.message);
4279
      });
4280
    });
Bogdan Timofte authored 4 days ago
4281
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4282
      if (!isAuthLost(e)) msg(e.message);
4283
    }));
Bogdan Timofte authored 4 days ago
4284
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4285

            
Bogdan Timofte authored 4 days ago
4286
    hostForm.addEventListener('submit', async (event) => {
Xdev Host Manager authored a week ago
4287
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4288
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4289
      setHostFormBusy(true);
4290
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4291
      try {
Bogdan Timofte authored 4 days ago
4292
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4293
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4294
        msg('host saved');
4295
        await refresh();
Bogdan Timofte authored 4 days ago
4296
        const host = state.hosts.find(entry => entry.id === savedId);
4297
        if (host) {
4298
          clearHostFormMessage();
4299
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4300
          hostField('aliases').value = (host.aliases || []).join('\n');
4301
          hostField('roles').value = (host.roles || []).join(' ');
4302
          hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4303
          activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', host.id || '', 'fqdn', false);
Bogdan Timofte authored 4 days ago
4304
        } else {
Bogdan Timofte authored 4 days ago
4305
          closeHostForm(true);
Bogdan Timofte authored 4 days ago
4306
        }
Bogdan Timofte authored 5 days ago
4307
      } catch (e) {
Bogdan Timofte authored 4 days ago
4308
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4309
        setHostFormMessage(e.message, true);
4310
        msg(e.message);
4311
      } finally {
4312
        setHostFormBusy(false);
4313
      }
4314
    });
4315

            
Bogdan Timofte authored 4 days ago
4316
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4317
      setHostFormMessage('Complete the required host fields before saving.', true);
4318
    }, true);
4319

            
Bogdan Timofte authored 4 days ago
4320
    hostForm.addEventListener('input', () => {
4321
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4322
    });
4323

            
Bogdan Timofte authored 4 days ago
4324
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4325
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4326
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4327
      setHostFormBusy(true);
4328
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4329
      try {
4330
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4331
        msg('host deleted');
4332
        await refresh();
Bogdan Timofte authored 4 days ago
4333
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4334
      } catch (e) {
Bogdan Timofte authored 4 days ago
4335
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4336
        setHostFormMessage(e.message, true);
4337
        msg(e.message);
4338
      } finally {
4339
        setHostFormBusy(false);
4340
      }
Xdev Host Manager authored a week ago
4341
    });
4342

            
Bogdan Timofte authored 4 days ago
4343
    resetHostForm(true);
4344
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4345

            
Xdev Host Manager authored a week ago
4346
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4347
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4348
      try {
4349
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4350
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4351
      } catch (e) {
4352
        if (!isAuthLost(e)) msg(e.message);
4353
      }
Xdev Host Manager authored a week ago
4354
    });
4355

            
Bogdan Timofte authored 4 days ago
4356
    refresh().catch(e => {
4357
      if (!isAuthLost(e)) showLogin(e.message);
4358
    });
Xdev Host Manager authored a week ago
4359
  </script>
4360
</body>
4361
</html>
4362
HTML
Bogdan Timofte authored 6 days ago
4363
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4364
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4365
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4366
    return $html;
Xdev Host Manager authored a week ago
4367
}