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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
1802
sub send_response {
1803
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1804
    my %reason = (200 => 'OK', 400 => 'Bad Request', 401 => 'Unauthorized', 404 => 'Not Found', 409 => 'Conflict', 500 => 'Internal Server Error', 503 => 'Service Unavailable');
Xdev Host Manager authored a week ago
1805
    $body = '' unless defined $body;
1806
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1807
    print $client "Content-Type: $type\r\n";
1808
    print $client "Content-Length: " . length($body) . "\r\n";
1809
    print $client "Cache-Control: no-store\r\n";
1810
    print $client "$_\r\n" for @{ $extra_headers || [] };
1811
    print $client "Connection: close\r\n\r\n";
1812
    print $client $body;
1813
}
1814

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

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

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

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

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

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

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

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

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

            
2142
    seed_mdns_observations_from_yaml($dbh);
2143
}
2144

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
2383
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2384
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2385
    my @names;
Bogdan Timofte authored 4 days ago
2386
    my $aliases = $dbh->prepare("SELECT alias_name FROM host_aliases WHERE host_fqdn = ? AND status = 'active' AND is_dns_published = 1 AND alias_kind NOT LIKE 'derived%' ORDER BY alias_name");
2387
    $aliases->execute($fqdn);
2388
    while (my ($name) = $aliases->fetchrow_array) {
2389
        push @names, $name;
2390
    }
Bogdan Timofte authored 4 days ago
2391
    return unique_preserve(@names);
2392
}
2393

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
3327
  </div>
3328

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

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

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

            
Bogdan Timofte authored 4 days ago
3393
    function isAuthLost(error) {
3394
      return !!(error && error.authLost);
3395
    }
3396

            
3397
    function authLostError(message) {
3398
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3399
      error.authLost = true;
3400
      return error;
3401
    }
3402

            
3403
    function handleAuthLost(message) {
3404
      state.authenticated = false;
3405
      msg('');
3406
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3407
    }
3408

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

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

            
Bogdan Timofte authored 5 days ago
3443
    function currentPage() {
3444
      return PAGE_PATHS[window.location.pathname] || 'overview';
3445
    }
3446

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

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

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

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

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

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

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

            
3514
      renderHosts();
Bogdan Timofte authored 4 days ago
3515
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3516
      renderVhosts();
Xdev Host Manager authored a week ago
3517
    }
3518

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

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

            
3596
    function certStatusClass(days) {
3597
      if (days === null) return '';
3598
      if (days < 0) return 'bad';
3599
      if (days <= 30) return 'warn';
3600
      return 'ok';
3601
    }
3602

            
3603
    function certStatusLabel(days) {
3604
      if (days === null) return 'validity unknown';
3605
      if (days < 0) return 'expired';
3606
      if (days === 0) return 'expires today';
3607
      return `${days}d remaining`;
3608
    }
3609

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

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

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

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

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

            
3713
    function debugTableReference(database, tableName) {
3714
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3715
    }
3716

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

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

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

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

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

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

            
3786
    function debugCell(value) {
3787
      if (value === null || value === undefined) return 'NULL';
3788
      if (Array.isArray(value)) return value.join(', ');
3789
      if (typeof value === 'object') return JSON.stringify(value);
3790
      return String(value);
3791
    }
3792

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

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

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

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

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

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

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

            
3954
    function renderVhostEditor() {
3955
      const select = $('vhost-new-host');
3956
      const current = select.value || '';
3957
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3958
    }
3959

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
4172
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4173
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4174
    }
4175

            
4176
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4177
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4178
    }
4179

            
4180
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4181
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4182
    }
4183

            
4184
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4185
      hostFormBusy = !!busy;
4186
      syncHostFormActions();
4187
    }
4188

            
4189
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4190
      saveHostButton.disabled = hostFormBusy;
4191
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4192
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4193
    }
4194

            
4195
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4196
      hostFormMessage.textContent = text || '';
4197
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4198
    }
4199

            
4200
    function clearHostFormMessage() {
4201
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4202
    }
4203

            
4204
    function formObject(form) {
4205
      return Object.fromEntries(new FormData(form).entries());
4206
    }
4207

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

            
Bogdan Timofte authored 6 days ago
4213
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4214

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

            
4220
    if (loginAccount) {
4221
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4222
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4223
      loginAccount.addEventListener('input', () => {
4224
        const value = (loginAccount.value || '').trim();
4225
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4226
      });
4227
    }
4228

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 4 days ago
4439
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4440
      setHostFormMessage('Complete the required host fields before saving.', true);
4441
    }, true);
4442

            
Bogdan Timofte authored 4 days ago
4443
    hostForm.addEventListener('input', () => {
4444
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4445
    });
4446

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

            
Bogdan Timofte authored 4 days ago
4466
    resetHostForm(true);
4467
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4468

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

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