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

            
6
use strict;
7
use warnings;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
2136
    seed_mdns_observations_from_yaml($dbh);
2137
}
2138

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
Bogdan Timofte authored 5 days ago
3221
      <section class="page" id="page-dns" data-page="dns" hidden>
3222
        <section class="toolbar">
3223
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3224
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3225
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3226
          <button id="write-tsv">Write local-hosts.tsv</button>
3227
        </section>
Xdev Host Manager authored a week ago
3228
      </section>
3229

            
Bogdan Timofte authored 5 days ago
3230
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3231
        <section class="panel">
3232
          <div class="panel-head">
3233
            <h2>Work Orders</h2>
3234
            <div class="stats" id="wo-stats"></div>
3235
          </div>
3236
          <div class="problems" id="work-orders"></div>
3237
        </section>
Xdev Host Manager authored a week ago
3238
      </section>
3239

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

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

            
3321
  </div>
3322

            
Bogdan Timofte authored 4 days ago
3323
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3324
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3325
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3326
  </div>
Bogdan Timofte authored 6 days ago
3327

            
Xdev Host Manager authored a week ago
3328
  <script>
Bogdan Timofte authored 4 days ago
3329
    let state = { hosts: [], vhosts: [], certificates: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3330
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3331
    let hostFormBusy = false;
3332
    let hostFormMode = 'new';
Bogdan Timofte authored 4 days ago
3333
    let hostEditorTarget = '';
Xdev Host Manager authored a week ago
3334

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

            
Bogdan Timofte authored 4 days ago
3387
    function isAuthLost(error) {
3388
      return !!(error && error.authLost);
3389
    }
3390

            
3391
    function authLostError(message) {
3392
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3393
      error.authLost = true;
3394
      return error;
3395
    }
3396

            
3397
    function handleAuthLost(message) {
3398
      state.authenticated = false;
3399
      msg('');
3400
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3401
    }
3402

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

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

            
Bogdan Timofte authored 5 days ago
3437
    function currentPage() {
3438
      return PAGE_PATHS[window.location.pathname] || 'overview';
3439
    }
3440

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

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

            
3471
    function showApp() {
Bogdan Timofte authored 6 days ago
3472
      document.body.classList.remove('is-login');
3473
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3474
      $('login-screen').style.display = 'none';
3475
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3476
      showPage(currentPage());
Xdev Host Manager authored a week ago
3477
    }
3478

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

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

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

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

            
3508
      renderHosts();
Bogdan Timofte authored 4 days ago
3509
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3510
      renderVhosts();
Xdev Host Manager authored a week ago
3511
    }
3512

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

            
Bogdan Timofte authored 5 days ago
3579
    function daysUntil(dateText) {
3580
      const time = Date.parse(dateText || '');
3581
      if (!Number.isFinite(time)) return null;
3582
      return Math.ceil((time - Date.now()) / 86400000);
3583
    }
3584

            
3585
    function certStatusClass(days) {
3586
      if (days === null) return '';
3587
      if (days < 0) return 'bad';
3588
      if (days <= 30) return 'warn';
3589
      return 'ok';
3590
    }
3591

            
3592
    function certStatusLabel(days) {
3593
      if (days === null) return 'validity unknown';
3594
      if (days < 0) return 'expired';
3595
      if (days === 0) return 'expires today';
3596
      return `${days}d remaining`;
3597
    }
3598

            
Xdev Host Manager authored a week ago
3599
    async function renderWorkOrders() {
3600
      try {
3601
        const data = await api('/api/work-orders');
3602
        state.workOrders = data.work_orders || [];
3603
        $('wo-stats').innerHTML = [
3604
          ['pending', data.counts.pending],
3605
          ['total', data.counts.work_orders],
3606
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3607

            
3608
        if (!state.workOrders.length) {
3609
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3610
          return;
3611
        }
3612

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

            
Bogdan Timofte authored 4 days ago
3652
    async function renderDebugDatabase() {
3653
      if (!state.authenticated) return;
3654
      const data = await api('/api/debug/database/tables');
3655
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3656
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3657
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3658
      $('debug-db-stats').innerHTML = [
3659
        ['tables', data.counts ? data.counts.tables : tables.length],
3660
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3661
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3662
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3663
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3664
      if (selected) {
3665
        await renderDebugTable(selected);
3666
      } else {
3667
        clearDebugTable();
3668
      }
3669
    }
3670

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

            
3702
    function debugTableReference(database, tableName) {
3703
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3704
    }
3705

            
3706
    async function selectDebugTable(tableName) {
3707
      state.debugTable = tableName || '';
3708
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3709
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3710
        const card = button.closest('.debug-table-card');
3711
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3712
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3713
      });
3714
      if (state.debugTable) await renderDebugTable(state.debugTable);
3715
    }
3716

            
3717
    function clearDebugTable() {
3718
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3719
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3720
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3721
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3722
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3723
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3724
    }
3725

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

            
Bogdan Timofte authored 4 days ago
3741
    function updateDebugExportLinks(tableName) {
3742
      const encoded = encodeURIComponent(tableName || '');
3743
      [
3744
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3745
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3746
      ].forEach(([id, href]) => {
3747
        const link = $(id);
3748
        const enabled = !!tableName;
3749
        link.href = enabled ? href : '#';
3750
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3751
      });
3752
    }
3753

            
Bogdan Timofte authored 4 days ago
3754
    function renderDebugRows(data) {
3755
      const rows = data.rows || [];
3756
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3757
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3758
    }
3759

            
3760
    function renderDebugObjectTable(rows, preferredKeys) {
3761
      const keys = preferredKeys && preferredKeys.length
3762
        ? preferredKeys
3763
        : Array.from(rows.reduce((set, row) => {
3764
            Object.keys(row || {}).forEach(key => set.add(key));
3765
            return set;
3766
          }, new Set()));
3767
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3768
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3769
      const body = rows.length
3770
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3771
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3772
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3773
    }
3774

            
3775
    function debugCell(value) {
3776
      if (value === null || value === undefined) return 'NULL';
3777
      if (Array.isArray(value)) return value.join(', ');
3778
      if (typeof value === 'object') return JSON.stringify(value);
3779
      return String(value);
3780
    }
3781

            
Xdev Host Manager authored a week ago
3782
    async function updateWorkOrderChecklist(id, itemId, checked) {
3783
      try {
3784
        await api('/api/work-orders/checklist', {
3785
          method: 'POST',
3786
          headers: { 'Content-Type': 'application/json' },
3787
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3788
        });
3789
        msg('work order updated');
3790
        await refresh();
Bogdan Timofte authored 4 days ago
3791
      } catch (e) {
3792
        if (isAuthLost(e)) return;
3793
        msg(e.message);
3794
        await refresh().catch(refreshError => {
3795
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3796
        });
3797
      }
Xdev Host Manager authored a week ago
3798
    }
3799

            
Xdev Host Manager authored a week ago
3800
    async function confirmWorkOrder(id) {
3801
      const typed = prompt(`Type ${id} to confirm this work order`);
3802
      if (typed !== id) return;
3803
      try {
3804
        await api('/api/work-orders/confirm', {
3805
          method: 'POST',
3806
          headers: { 'Content-Type': 'application/json' },
3807
          body: JSON.stringify({ id, confirm: typed })
3808
        });
3809
        msg('work order confirmed; local-hosts.tsv written');
3810
        await refresh();
Bogdan Timofte authored 4 days ago
3811
      } catch (e) {
3812
        if (isAuthLost(e)) return;
3813
        msg(e.message);
3814
      }
Xdev Host Manager authored a week ago
3815
    }
3816

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

            
Bogdan Timofte authored 4 days ago
3844
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3845
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3846
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3847
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3848
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3849
    }
3850

            
3851
    function vhostRows() {
Bogdan Timofte authored 4 days ago
3852
      if (state.vhosts && state.vhosts.length) return state.vhosts;
Bogdan Timofte authored 4 days ago
3853
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3854
        vhost,
3855
        host_id: host.id || '',
3856
        host_fqdn: host.fqdn || '',
3857
        ip: host.ip || '',
3858
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3859
        monitoring: host.monitoring || '',
3860
        status: host.status || '',
Bogdan Timofte authored 4 days ago
3861
        certificate_id: '',
3862
        certificate: null,
Bogdan Timofte authored 4 days ago
3863
      })));
3864
    }
3865

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

            
3924
    function renderVhostCertificateCell(row) {
3925
      const cert = row.certificate || {};
3926
      const certId = row.certificate_id || cert.id || cert.name || '';
3927
      const links = certId ? `<div class="vhost-cert-links">
3928
        <a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(certId)}.crt">crt</a>
3929
        ${cert.has_private_key ? `<a class="linkbtn" href="/download/ca/key/${encodeURIComponent(certId)}.key">key</a>` : ''}
3930
      </div>` : '';
3931
      const validity = cert.not_after ? `<span class="muted vhost-cert-validity">${escapeHtml(certStatusLabel(daysUntil(cert.not_after)))}</span>` : '';
3932
      return `<div class="vhost-cert">
3933
        <div class="vhost-cert-main">
3934
          <select class="vhost-cert-select" data-vhost-cert-select="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">
3935
            ${renderCertificateOptions(certId)}
3936
          </select>
3937
          <button type="button" data-vhost-cert-issue="${escapeHtml(row.vhost)}" data-current-certificate="${escapeHtml(certId)}">Issue</button>
3938
        </div>
3939
        <div class="vhost-cert-meta">${links}${validity}</div>
3940
      </div>`;
Bogdan Timofte authored 4 days ago
3941
    }
3942

            
3943
    function renderVhostEditor() {
3944
      const select = $('vhost-new-host');
3945
      const current = select.value || '';
3946
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3947
    }
3948

            
3949
    function renderVhostHostOptions(selectedHostFqdn) {
3950
      return state.hosts
3951
        .slice()
3952
        .filter(host => (host.status || '') !== 'retired')
3953
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3954
        .map(host => {
3955
          const fqdn = host.fqdn || '';
3956
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
3957
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
3958
        }).join('');
Bogdan Timofte authored 4 days ago
3959
    }
3960

            
Bogdan Timofte authored 4 days ago
3961
    function renderCertificateOptions(selectedCertificateId) {
3962
      const certs = (state.certificates || [])
3963
        .slice()
3964
        .sort((a, b) => String(a.name || a.id || '').localeCompare(String(b.name || b.id || '')));
3965
      const options = ['<option value="">no certificate</option>'].concat(certs.map(cert => {
3966
        const id = cert.id || cert.name || '';
3967
        const label = cert.name || cert.id || '';
3968
        const selected = id === selectedCertificateId ? ' selected' : '';
3969
        return `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(label)}</option>`;
3970
      }));
3971
      return options.join('');
3972
    }
3973

            
Bogdan Timofte authored 4 days ago
3974
    function shortAliasForFqdn(name) {
3975
      const suffix = '.madagascar.xdev.ro';
3976
      name = String(name || '').toLowerCase();
3977
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3978
    }
3979

            
Bogdan Timofte authored 4 days ago
3980
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
3981
      const vhost = select.dataset.vhostSelect || '';
3982
      const fromHost = select.dataset.currentHost || '';
3983
      const toHost = select.value || '';
3984
      if (!vhost || !toHost || toHost === fromHost) return;
3985
      select.disabled = true;
3986
      try {
3987
        await api('/api/vhosts/reassign', {
3988
          method: 'POST',
3989
          headers: { 'Content-Type': 'application/json' },
3990
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3991
        });
3992
        msg(`vhost ${vhost} moved`);
3993
        await refresh();
3994
      } finally {
3995
        select.disabled = false;
3996
      }
3997
    }
3998

            
Bogdan Timofte authored 4 days ago
3999
    async function addVhostInline() {
4000
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
4001
      const nameInput = $('vhost-new-name');
4002
      const hostSelect = $('vhost-new-host');
4003
      const vhost = (nameInput.value || '').trim().toLowerCase();
4004
      const hostFqdn = hostSelect.value || '';
4005
      if (!vhost || !hostFqdn) return;
4006
      $('vhost-add').disabled = true;
4007
      nameInput.disabled = true;
4008
      hostSelect.disabled = true;
4009
      try {
4010
        await api('/api/vhosts/upsert', {
4011
          method: 'POST',
4012
          headers: { 'Content-Type': 'application/json' },
4013
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
4014
        });
4015
        nameInput.value = '';
4016
        msg(`vhost ${vhost} saved`);
4017
        await refresh();
4018
      } finally {
4019
        $('vhost-add').disabled = false;
4020
        nameInput.disabled = false;
4021
        hostSelect.disabled = false;
4022
      }
4023
    }
4024

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

            
Bogdan Timofte authored 4 days ago
4052
    async function issueVhostCertificate(vhost, currentCertificateId, button) {
Bogdan Timofte authored 4 days ago
4053
      if (!await ensureAuthenticated('Autentifica-te inainte de emitere.')) return;
4054
      if (!vhost) return;
4055
      if (currentCertificateId && !confirm(`Issue a new certificate for ${vhost} and replace the current association?`)) return;
4056
      if (button) button.disabled = true;
4057
      try {
4058
        const result = await api('/api/vhosts/issue-certificate', {
4059
          method: 'POST',
4060
          headers: { 'Content-Type': 'application/json' },
4061
          body: JSON.stringify({ vhost_fqdn: vhost }),
4062
        });
4063
        msg(`certificate ${result.certificate_id || ''} issued for ${vhost}`);
4064
        await refresh();
4065
      } finally {
4066
        if (button) button.disabled = false;
4067
      }
4068
    }
4069

            
Bogdan Timofte authored 4 days ago
4070
    async function deleteVhostInline(vhost) {
4071
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
4072
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
4073
      await api('/api/vhosts/delete', {
4074
        method: 'POST',
4075
        headers: { 'Content-Type': 'application/json' },
4076
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
4077
      });
4078
      msg(`vhost ${vhost} deleted`);
4079
      await refresh();
4080
    }
4081

            
Bogdan Timofte authored 4 days ago
4082
    async function editHost(id) {
4083
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
4084
      const host = state.hosts.find(h => h.id === id);
4085
      if (!host) return;
Bogdan Timofte authored 4 days ago
4086
      if (!canSwitchHostEditor(id)) return;
Bogdan Timofte authored 5 days ago
4087
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4088
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4089
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
4090
      hostField('roles').value = (host.roles || []).join(' ');
4091
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
4092
      activateHostForm(`Edit host ${host.id || ''}`.trim(), 'edit', id, 'fqdn');
Bogdan Timofte authored 5 days ago
4093
    }
4094

            
Bogdan Timofte authored 4 days ago
4095
    async function newHost() {
4096
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
4097
      if (!canSwitchHostEditor('__new__')) return;
4098
      resetHostForm(true);
4099
      activateHostForm('New host', 'new', '__new__', 'id');
Bogdan Timofte authored 5 days ago
4100
    }
4101

            
Bogdan Timofte authored 4 days ago
4102
    function activateHostForm(title, mode, target, focusField = 'id', scroll = true) {
Bogdan Timofte authored 4 days ago
4103
      hostFormMode = mode || 'new';
Bogdan Timofte authored 4 days ago
4104
      hostEditorTarget = target || '';
4105
      hostFormTitle.textContent = title || 'New host';
Bogdan Timofte authored 4 days ago
4106
      syncHostFormActions();
Bogdan Timofte authored 4 days ago
4107
      renderHosts();
4108
      hostFormSnapshot = hostFormState();
4109
      if (scroll) hostEditorRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
Bogdan Timofte authored 4 days ago
4110
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
4111
    }
4112

            
Bogdan Timofte authored 4 days ago
4113
    function resetHostForm(force = false) {
Bogdan Timofte authored 4 days ago
4114
      if (hostFormBusy && !force) return;
Bogdan Timofte authored 4 days ago
4115
      hostForm.reset();
Bogdan Timofte authored 5 days ago
4116
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
4117
      hostField('status').value = 'active';
4118
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 4 days ago
4119
      hostFormSnapshot = force ? '' : hostFormState();
4120
    }
4121

            
4122
    function closeHostForm(force = false) {
4123
      if (hostFormBusy && !force) return;
4124
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
4125
      hostEditorTarget = '';
4126
      hostFormMode = 'new';
4127
      hostFormSnapshot = '';
4128
      clearHostFormMessage();
4129
      syncHostFormActions();
4130
      mountHostEditor();
4131
    }
4132

            
4133
    function canSwitchHostEditor(target) {
4134
      if (hostFormBusy) return false;
4135
      if (!hostEditorTarget) return true;
4136
      if (!hostFormDirty()) return true;
4137
      if (hostEditorTarget === target) return confirm('Discard unsaved host changes and reload this editor?');
4138
      return confirm('Discard unsaved host changes?');
4139
    }
4140

            
4141
    function mountHostEditor() {
4142
      hostEditorRow.remove();
4143
      if (!hostEditorTarget) {
4144
        hostFormShell.hidden = true;
4145
        return;
4146
      }
4147
      hostEditorCell.colSpan = 7;
4148
      const tbody = $('hosts');
4149
      if (!tbody) return;
4150
      if (hostEditorTarget === '__new__') {
4151
        tbody.prepend(hostEditorRow);
4152
      } else {
4153
        const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
4154
        const targetRow = rows.find(row => row.dataset.id === hostEditorTarget);
4155
        if (targetRow) targetRow.after(hostEditorRow);
4156
        else tbody.prepend(hostEditorRow);
4157
      }
4158
      hostFormShell.hidden = false;
Bogdan Timofte authored 5 days ago
4159
    }
4160

            
4161
    function hostField(name) {
Bogdan Timofte authored 4 days ago
4162
      return hostForm.elements.namedItem(name);
Bogdan Timofte authored 5 days ago
4163
    }
4164

            
4165
    function hostFormState() {
Bogdan Timofte authored 4 days ago
4166
      return JSON.stringify(formObject(hostForm));
Bogdan Timofte authored 5 days ago
4167
    }
4168

            
4169
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
4170
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
4171
    }
4172

            
4173
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
4174
      hostFormBusy = !!busy;
4175
      syncHostFormActions();
4176
    }
4177

            
4178
    function syncHostFormActions() {
Bogdan Timofte authored 4 days ago
4179
      saveHostButton.disabled = hostFormBusy;
4180
      deleteHostButton.disabled = hostFormBusy || hostFormMode !== 'edit';
4181
      cancelHostButton.disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
4182
    }
4183

            
4184
    function setHostFormMessage(text, isError = false) {
Bogdan Timofte authored 4 days ago
4185
      hostFormMessage.textContent = text || '';
4186
      hostFormMessage.classList.toggle('error', !!isError);
Bogdan Timofte authored 5 days ago
4187
    }
4188

            
4189
    function clearHostFormMessage() {
4190
      setHostFormMessage('');
Xdev Host Manager authored a week ago
4191
    }
4192

            
4193
    function formObject(form) {
4194
      return Object.fromEntries(new FormData(form).entries());
4195
    }
4196

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

            
Bogdan Timofte authored 6 days ago
4202
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
4203

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

            
4209
    if (loginAccount) {
4210
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
4211
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
4212
      loginAccount.addEventListener('input', () => {
4213
        const value = (loginAccount.value || '').trim();
4214
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
4215
      });
4216
    }
4217

            
Xdev Host Manager authored a week ago
4218
    function setOtpDigit(idx, value) {
4219
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
4220
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
4221
      otpDigits[idx].classList.toggle('filled', !!digit);
4222
    }
4223

            
Bogdan Timofte authored 4 days ago
4224
    // Move focus to the next empty box: forward from idx, then wrapping to the
4225
    // start. This lets out-of-order entry continue (e.g. after the last box,
4226
    // jump back to the first still-empty box). Stays put when all boxes are full.
4227
    function advanceFocus(idx) {
4228
      for (let i = idx + 1; i < otpDigits.length; i++) {
4229
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4230
      }
4231
      for (let i = 0; i <= idx; i++) {
4232
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
4233
      }
4234
    }
4235

            
Bogdan Timofte authored 4 days ago
4236
    // Spread multiple digits across boxes starting at startIdx. Used for paste
4237
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
4238
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
4239
      const digits = (text || '').replace(/\D/g, '').split('');
4240
      if (!digits.length) return;
4241
      let last = startIdx;
4242
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
4243
        last = startIdx + i;
4244
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
4245
      }
Bogdan Timofte authored 4 days ago
4246
      syncOtpFields();
Bogdan Timofte authored 4 days ago
4247
      advanceFocus(last);
Xdev Host Manager authored a week ago
4248
      maybeSubmitOtp();
4249
    }
4250

            
Bogdan Timofte authored 4 days ago
4251
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
4252
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
4253
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
4254
    function maybeSubmitOtp() {
4255
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
4256
    }
4257
    function clearOtp() {
Bogdan Timofte authored 4 days ago
4258
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
4259
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
4260
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
4261
      // an unknown operator, so Safari's autofill anchor on the username stays.
4262
      if (loginAccount && !loginAccount.value) loginAccount.focus();
4263
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
4264
    }
4265

            
Bogdan Timofte authored 4 days ago
4266
    otpDigits.forEach((input, idx) => {
4267
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
4268
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4269
        // A single box may receive several digits at once (autofill / typing fast).
4270
        if (input.value.replace(/\D/g, '').length > 1) {
4271
          fillOtp(input.value, idx);
4272
          return;
4273
        }
4274
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
4275
        syncOtpFields();
Bogdan Timofte authored 4 days ago
4276
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
4277
        maybeSubmitOtp();
4278
      });
Bogdan Timofte authored 4 days ago
4279

            
4280
      input.addEventListener('paste', (e) => {
4281
        e.preventDefault();
Bogdan Timofte authored 4 days ago
4282
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4283
        const text = (e.clipboardData || window.clipboardData).getData('text');
4284
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
4285
      });
Bogdan Timofte authored 4 days ago
4286

            
4287
      input.addEventListener('keydown', (e) => {
4288
        if (e.key === 'Backspace') {
4289
          e.preventDefault();
Bogdan Timofte authored 4 days ago
4290
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
4291
          if (input.value) { setOtpDigit(idx, ''); }
4292
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
4293
          syncOtpFields();
4294
        } else if (e.key === 'ArrowLeft' && idx > 0) {
4295
          e.preventDefault();
4296
          otpDigits[idx - 1].focus();
4297
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
4298
          e.preventDefault();
4299
          otpDigits[idx + 1].focus();
4300
        }
4301
      });
4302
    });
4303

            
Bogdan Timofte authored 4 days ago
4304
    // Focus the first OTP box only for a returning operator (username known).
4305
    // For an unknown operator, leave focus on the username field so Safari can
4306
    // present its OTP autofill anchored there without being dismissed by a focus
4307
    // change (pbx-admin pattern).
4308
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
4309
    else if (loginAccount) loginAccount.focus();
4310
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
4311

            
Bogdan Timofte authored 5 days ago
4312
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
4313
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
4314
        event.preventDefault();
Bogdan Timofte authored 4 days ago
4315
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
4316
        showPage(link.dataset.pageLink, true);
4317
      });
4318
    });
4319

            
Bogdan Timofte authored 4 days ago
4320
    window.addEventListener('popstate', () => {
4321
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
4322
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
4323
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
4324
    });
Bogdan Timofte authored 5 days ago
4325

            
Bogdan Timofte authored 4 days ago
4326
    async function copyText(text) {
4327
      if (navigator.clipboard && window.isSecureContext) {
4328
        await navigator.clipboard.writeText(text);
4329
        return;
4330
      }
4331
      const input = document.createElement('textarea');
4332
      input.value = text;
4333
      input.setAttribute('readonly', '');
4334
      input.style.position = 'fixed';
4335
      input.style.left = '-10000px';
4336
      document.body.appendChild(input);
4337
      input.select();
4338
      document.execCommand('copy');
4339
      document.body.removeChild(input);
4340
    }
4341

            
4342
    $('copy-build').addEventListener('click', async () => {
4343
      try {
4344
        await copyText($('copy-build').dataset.buildDetails || '');
4345
        if (state.authenticated) msg('build details copied');
4346
      } catch (e) {
4347
        if (state.authenticated) msg('copy failed');
4348
      }
4349
    });
4350

            
Xdev Host Manager authored a week ago
4351
    $('login-form').addEventListener('submit', async (event) => {
4352
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4353
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4354
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4355
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4356
      try {
Xdev Host Manager authored a week ago
4357
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4358
        await refresh();
Xdev Host Manager authored a week ago
4359
      } catch (e) {
4360
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4361
      } finally {
Xdev Host Manager authored a week ago
4362
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4363
      }
Xdev Host Manager authored a week ago
4364
    });
4365

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

            
Bogdan Timofte authored 4 days ago
4371
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4372
      if (!isAuthLost(e)) msg(e.message);
4373
    }));
Xdev Host Manager authored a week ago
4374
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4375
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4376
    $('vhost-add').addEventListener('click', () => {
4377
      addVhostInline().catch(e => {
4378
        if (!isAuthLost(e)) msg(e.message);
4379
      });
4380
    });
4381
    $('vhost-new-name').addEventListener('keydown', (event) => {
4382
      if (event.key !== 'Enter') return;
4383
      event.preventDefault();
4384
      addVhostInline().catch(e => {
4385
        if (!isAuthLost(e)) msg(e.message);
4386
      });
4387
    });
Bogdan Timofte authored 4 days ago
4388
    $('new-host').addEventListener('click', () => {
4389
      newHost().catch(e => {
4390
        if (!isAuthLost(e)) msg(e.message);
4391
      });
4392
    });
Bogdan Timofte authored 4 days ago
4393
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4394
      if (!isAuthLost(e)) msg(e.message);
4395
    }));
Bogdan Timofte authored 4 days ago
4396
    cancelHostButton.addEventListener('click', () => closeHostForm());
Xdev Host Manager authored a week ago
4397

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

            
Bogdan Timofte authored 4 days ago
4428
    hostForm.addEventListener('invalid', (event) => {
Bogdan Timofte authored 5 days ago
4429
      setHostFormMessage('Complete the required host fields before saving.', true);
4430
    }, true);
4431

            
Bogdan Timofte authored 4 days ago
4432
    hostForm.addEventListener('input', () => {
4433
      if (hostFormMessage.classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4434
    });
4435

            
Bogdan Timofte authored 4 days ago
4436
    deleteHostButton.addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4437
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4438
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4439
      setHostFormBusy(true);
4440
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4441
      try {
4442
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4443
        msg('host deleted');
4444
        await refresh();
Bogdan Timofte authored 4 days ago
4445
        closeHostForm(true);
Bogdan Timofte authored 5 days ago
4446
      } catch (e) {
Bogdan Timofte authored 4 days ago
4447
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4448
        setHostFormMessage(e.message, true);
4449
        msg(e.message);
4450
      } finally {
4451
        setHostFormBusy(false);
4452
      }
Xdev Host Manager authored a week ago
4453
    });
4454

            
Bogdan Timofte authored 4 days ago
4455
    resetHostForm(true);
4456
    closeHostForm(true);
Bogdan Timofte authored 4 days ago
4457

            
Xdev Host Manager authored a week ago
4458
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4459
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4460
      try {
4461
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4462
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4463
      } catch (e) {
4464
        if (!isAuthLost(e)) msg(e.message);
4465
      }
Xdev Host Manager authored a week ago
4466
    });
4467

            
Bogdan Timofte authored 4 days ago
4468
    refresh().catch(e => {
4469
      if (!isAuthLost(e)) showLogin(e.message);
4470
    });
Xdev Host Manager authored a week ago
4471
  </script>
4472
</body>
4473
</html>
4474
HTML
Bogdan Timofte authored 6 days ago
4475
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4476
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4477
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4478
    return $html;
Xdev Host Manager authored a week ago
4479
}