LocalAuthority / scripts / host_manager.pl
Newer Older
3879 lines | 149.284kb
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
    }
Xdev Host Manager authored a week ago
195

            
196
    if ($method eq 'POST' && $path =~ m{^/api/}) {
197
        if ($path eq '/api/hosts/upsert') {
198
            my $payload = request_payload(\%headers, $body);
199
            return upsert_host($client, $payload);
200
        }
201
        if ($path eq '/api/hosts/delete') {
202
            my $payload = request_payload(\%headers, $body);
203
            return delete_host($client, $payload->{id} || '');
204
        }
Xdev Host Manager authored a week ago
205
        if ($path eq '/api/work-orders/confirm') {
206
            my $payload = request_payload(\%headers, $body);
207
            return confirm_work_order($client, $payload);
208
        }
Xdev Host Manager authored a week ago
209
        if ($path eq '/api/work-orders/checklist') {
210
            my $payload = request_payload(\%headers, $body);
211
            return update_work_order_checklist($client, $payload);
212
        }
Xdev Host Manager authored a week ago
213
        if ($path eq '/api/render/local-hosts-tsv') {
214
            my $registry = load_registry();
215
            my $content = render_local_hosts_tsv($registry);
216
            backup_file($opt{local_hosts_tsv});
217
            write_file($opt{local_hosts_tsv}, $content);
218
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
219
        }
220
    }
221

            
222
    return send_json($client, 404, { error => 'not_found' });
223
}
224

            
Bogdan Timofte authored 5 days ago
225
sub app_page_path {
226
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
227
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
228
}
229

            
Xdev Host Manager authored a week ago
230
sub load_registry {
Bogdan Timofte authored 4 days ago
231
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
232
    normalize_registry_policy($registry);
233
    return $registry;
Xdev Host Manager authored a week ago
234
}
235

            
236
sub save_registry {
237
    my ($registry) = @_;
238
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
239
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
240
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
241
}
242

            
Xdev Host Manager authored a week ago
243
sub load_work_orders {
Bogdan Timofte authored 4 days ago
244
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
245
}
246

            
247
sub save_work_orders {
248
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
249
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
250
}
251

            
252
sub work_orders_payload {
253
    my ($orders) = @_;
254
    my $pending = 0;
255
    for my $wo (@{ $orders->{work_orders} || [] }) {
256
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
257
    }
258
    return {
259
        version => $orders->{version},
260
        work_orders => $orders->{work_orders} || [],
261
        counts => {
262
            work_orders => scalar @{ $orders->{work_orders} || [] },
263
            pending => $pending,
264
        },
265
    };
266
}
267

            
268
sub confirm_work_order {
269
    my ($client, $payload) = @_;
270
    my $id = clean_scalar($payload->{id} || '');
271
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
272
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
273

            
274
    my $orders = load_work_orders();
275
    my $work_order;
276
    for my $wo (@{ $orders->{work_orders} || [] }) {
277
        if (($wo->{id} || '') eq $id) {
278
            $work_order = $wo;
279
            last;
280
        }
281
    }
282
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
283
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
284
    my $incomplete = incomplete_work_order_items($work_order);
285
    return send_json($client, 409, {
286
        error => 'work_order_incomplete',
287
        incomplete => $incomplete,
288
    }) if @$incomplete;
Xdev Host Manager authored a week ago
289

            
290
    my $registry = load_registry();
291
    my $results = apply_work_order($registry, $work_order);
292
    $work_order->{status} = 'confirmed';
293
    $work_order->{confirmed_at} = iso_now();
294
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
295

            
296
    save_registry($registry);
297
    save_work_orders($orders);
298
    backup_file($opt{local_hosts_tsv});
299
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
300

            
301
    return send_json($client, 200, {
302
        ok => json_bool(1),
303
        work_order => $work_order,
304
        results => $results,
305
        local_hosts_tsv => $opt{local_hosts_tsv},
306
    });
307
}
308

            
Xdev Host Manager authored a week ago
309
sub update_work_order_checklist {
310
    my ($client, $payload) = @_;
311
    my $id = clean_scalar($payload->{id} || '');
312
    my $item_id = clean_scalar($payload->{item_id} || '');
313
    my $status = clean_scalar($payload->{status} || '');
314
    my $notes = clean_scalar($payload->{notes} || '');
315
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
316
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
317
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
318

            
319
    my $orders = load_work_orders();
320
    my $work_order;
321
    for my $wo (@{ $orders->{work_orders} || [] }) {
322
        if (($wo->{id} || '') eq $id) {
323
            $work_order = $wo;
324
            last;
325
        }
326
    }
327
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
328
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
329

            
330
    my $item;
331
    for my $candidate (@{ $work_order->{checklist} || [] }) {
332
        if (($candidate->{id} || '') eq $item_id) {
333
            $item = $candidate;
334
            last;
335
        }
336
    }
337
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
338

            
339
    $item->{status} = $status;
340
    $item->{updated_at} = iso_now();
341
    $item->{notes} = $notes if length $notes;
342
    save_work_orders($orders);
343
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
344
}
345

            
346
sub incomplete_work_order_items {
347
    my ($work_order) = @_;
348
    my @incomplete;
349
    for my $item (@{ $work_order->{checklist} || [] }) {
350
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
351
    }
352
    return \@incomplete;
353
}
354

            
Xdev Host Manager authored a week ago
355
sub apply_work_order {
356
    my ($registry, $work_order) = @_;
357
    my @results;
358
    for my $action (@{ $work_order->{actions} || [] }) {
359
        my $type = $action->{type} || '';
360
        if ($type eq 'remove_name') {
361
            my $host_id = $action->{host_id} || '';
362
            my $name = $action->{name} || '';
363
            my $removed = 0;
364
            for my $host (@{ $registry->{hosts} || [] }) {
365
                next unless ($host->{id} || '') eq $host_id;
Bogdan Timofte authored 4 days ago
366
                my @kept_aliases = grep { $_ ne $name } declared_alias_names($host);
367
                my @kept_vhosts = grep { $_ ne $name } declared_vhost_names($host);
368
                $removed = (@kept_aliases != @{ $host->{aliases} || [] }) || (@kept_vhosts != @{ $host->{vhosts} || [] });
369
                $host->{aliases} = \@kept_aliases;
370
                $host->{vhosts} = \@kept_vhosts;
Xdev Host Manager authored a week ago
371
                last;
372
            }
373
            push @results, {
374
                type => $type,
375
                host_id => $host_id,
376
                name => $name,
377
                removed => json_bool($removed),
378
            };
379
        } else {
380
            die "Unsupported work order action: $type\n";
381
        }
382
    }
383
    return \@results;
384
}
385

            
Xdev Host Manager authored a week ago
386
sub registry_payload {
387
    my ($registry) = @_;
388
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
389
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Bogdan Timofte authored 4 days ago
390
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
391
    return {
392
        version => $registry->{version},
393
        updated_at => $registry->{updated_at},
394
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
395
        hosts => \@hosts,
Xdev Host Manager authored a week ago
396
        problems => $problems,
397
        counts => {
398
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 4 days ago
399
            vhosts => $vhost_count,
Xdev Host Manager authored a week ago
400
            problems => scalar @$problems,
401
        },
402
    };
403
}
404

            
405
sub upsert_host {
406
    my ($client, $payload) = @_;
407
    my $id = clean_id($payload->{id} || '');
408
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
409

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

            
Bogdan Timofte authored 4 days ago
413
    my $fqdn = canonical_host_fqdn($payload);
414
    return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
415
    my @aliases = clean_alias_names($payload);
416
    my @vhosts = clean_vhost_names($payload);
Xdev Host Manager authored a week ago
417

            
418
    my $registry = load_registry();
419
    my %host = (
420
        id => $id,
Bogdan Timofte authored 4 days ago
421
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
422
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
423
        ip => $ip,
424
        aliases => \@aliases,
425
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
426
        roles => [ clean_list($payload->{roles}) ],
427
        sources => [ clean_list($payload->{sources}) ],
428
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
429
        notes => clean_scalar($payload->{notes} || ''),
430
    );
431

            
Bogdan Timofte authored 4 days ago
432
    my $response = eval {
433
        my $replaced = 0;
434
        for my $i (0 .. $#{ $registry->{hosts} }) {
435
            if ($registry->{hosts}->[$i]{id} eq $id) {
436
                $registry->{hosts}->[$i] = \%host;
437
                $replaced = 1;
438
                last;
439
            }
Xdev Host Manager authored a week ago
440
        }
Bogdan Timofte authored 4 days ago
441
        push @{ $registry->{hosts} }, \%host unless $replaced;
442
        save_registry($registry);
443
        1;
444
    };
445
    if (!$response) {
446
        my $err = $@ || 'upsert_failed';
447
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
448
            if $err =~ /alias_conflict:/;
449
        die $err;
Xdev Host Manager authored a week ago
450
    }
451
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
452
}
453

            
454
sub delete_host {
455
    my ($client, $id) = @_;
456
    $id = clean_id($id);
457
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
458

            
459
    my $registry = load_registry();
460
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
461
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
462
    $registry->{hosts} = \@kept;
463
    save_registry($registry);
464
    return send_json($client, 200, { ok => json_bool(1) });
465
}
466

            
467
sub analyze_hosts {
468
    my ($hosts) = @_;
469
    my @problems;
470
    my (%names, %ids);
471
    for my $host (@$hosts) {
472
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
473
        my $fqdn = canonical_host_fqdn($host);
474
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
475
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
476
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
477
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
478
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
479
            if grep { /^(is|vad|b)-/ } @declared;
480
        for my $name (@declared) {
Xdev Host Manager authored a week ago
481
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
482
        }
Bogdan Timofte authored 4 days ago
483
        my %declared = map { $_ => 1 } @declared;
484
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
485
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
486
                if $declared{$derived};
487
        }
Bogdan Timofte authored 4 days ago
488
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
489
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
490
    }
491
    return \@problems;
492
}
493

            
Xdev Host Manager authored a week ago
494
sub host_payload {
495
    my ($host) = @_;
496
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
497
    $copy{fqdn} = canonical_host_fqdn($host);
498
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
499
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
500
    $copy{declared_names} = [ declared_dns_names($host) ];
501
    $copy{aliases} = [ declared_alias_names($host) ];
502
    $copy{derived_aliases} = [ derived_alias_names($host) ];
503
    $copy{vhosts} = [ declared_vhost_names($host) ];
504
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Xdev Host Manager authored a week ago
505
    return \%copy;
506
}
507

            
508
sub effective_names {
509
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
510
    my @names = declared_dns_names($host);
511
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
512
    return unique_preserve(@names);
513
}
514

            
Bogdan Timofte authored 4 days ago
515
sub declared_dns_names {
516
    my ($host) = @_;
517
    my @names;
518
    my $fqdn = canonical_host_fqdn($host);
519
    push @names, $fqdn if length $fqdn;
520
    push @names, declared_alias_names($host);
521
    push @names, declared_vhost_names($host);
522
    return unique_preserve(@names);
523
}
524

            
525
sub declared_alias_names {
526
    my ($host) = @_;
527
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
528
}
529

            
530
sub declared_vhost_names {
531
    my ($host) = @_;
532
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
533
}
534

            
535
sub declared_dns_names_legacy {
536
    my ($host) = @_;
537
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
538
}
539

            
540
sub split_legacy_names {
541
    my ($id, $names) = @_;
542
    my $fallback = clean_id($id || '');
543
    my (%result) = (
544
        fqdn => '',
545
        aliases => [],
546
        vhosts => [],
547
    );
548
    for my $name (map { normalize_dns_name($_) } @$names) {
549
        next unless length $name;
550
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
551
            $result{fqdn} = $name;
552
            next;
553
        }
554
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
555
            $result{fqdn} = $name;
556
            next;
557
        }
558
        if (name_is_vhost($name)) {
559
            push @{ $result{vhosts} }, $name;
560
        } else {
561
            push @{ $result{aliases} }, $name;
562
        }
563
    }
564
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
565
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
566
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
567
    return \%result;
568
}
569

            
570
sub derived_alias_names {
Xdev Host Manager authored a week ago
571
    my ($host) = @_;
572
    my @derived;
Bogdan Timofte authored 4 days ago
573
    my $fqdn = canonical_host_fqdn($host);
574
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
575
    for my $name (declared_alias_names($host)) {
576
        push @derived, short_alias_for_fqdn($name);
577
    }
578
    return unique_preserve(grep { length $_ } @derived);
579
}
580

            
581
sub derived_vhost_alias_names {
582
    my ($host) = @_;
583
    my @derived;
584
    for my $name (declared_vhost_names($host)) {
585
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
586
    }
Bogdan Timofte authored 4 days ago
587
    return unique_preserve(grep { length $_ } @derived);
588
}
589

            
590
sub clean_alias_names {
591
    my ($payload) = @_;
592
    return clean_name_bucket($payload->{aliases})
593
        if defined $payload->{aliases};
594
    my @legacy = remove_derived_names(clean_list($payload->{names}));
595
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
596
}
597

            
598
sub clean_vhost_names {
599
    my ($payload) = @_;
600
    return clean_name_bucket($payload->{vhosts})
601
        if defined $payload->{vhosts};
602
    my @legacy = remove_derived_names(clean_list($payload->{names}));
603
    return grep { name_is_vhost($_) } @legacy;
604
}
605

            
606
sub clean_name_bucket {
607
    my ($value) = @_;
608
    my @names = clean_list($value);
609
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
610
}
611

            
612
sub remove_derived_names {
613
    my @names = @_;
614
    my %derived;
615
    for my $name (@names) {
616
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
617
        $derived{$1} = 1;
618
    }
619
    return grep { !$derived{$_} } @names;
620
}
621

            
622
sub unique_preserve {
623
    my @values = @_;
624
    my %seen;
625
    return grep { !$seen{$_}++ } @values;
626
}
627

            
Bogdan Timofte authored 4 days ago
628
sub canonical_ip {
629
    my ($host) = @_;
630
    return '' unless $host && ref($host) eq 'HASH';
631
    for my $key (qw(ip dns_ip hosts_ip)) {
632
        my $value = clean_scalar($host->{$key} || '');
633
        return $value if length $value;
634
    }
635
    return '';
636
}
637

            
Xdev Host Manager authored a week ago
638
sub problem {
639
    my ($host, $code, $message) = @_;
640
    return { host_id => $host->{id}, code => $code, message => $message };
641
}
642

            
643
sub render_local_hosts_tsv {
644
    my ($registry) = @_;
645
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
646
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
647
    $out .= "#\n";
648
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
649
    $out .= "# ip<TAB>name [aliases...]\n";
Xdev Host Manager authored a week ago
650
    $out .= "#\n";
651
    $out .= "# Priority rule:\n";
652
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
653
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
654
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
655
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
656
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
657
        my $ip = canonical_ip($host);
658
        next unless $ip;
Xdev Host Manager authored a week ago
659
        my @names = effective_names($host);
660
        next unless @names;
Bogdan Timofte authored 4 days ago
661
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
662
    }
663
    return $out;
664
}
665

            
666
sub render_monitoring {
667
    my ($registry) = @_;
668
    my @hosts;
669
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
670
        next unless ($host->{status} || 'active') eq 'active';
671
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
672
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
673
        push @hosts, {
674
            id => $host->{id},
Xdev Host Manager authored a week ago
675
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
676
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
677
            aliases => \@names,
Bogdan Timofte authored 4 days ago
678
            fqdn => canonical_host_fqdn($host),
679
            declared_names => [ declared_dns_names($host) ],
680
            aliases_declared => [ declared_alias_names($host) ],
681
            aliases_derived => [ derived_alias_names($host) ],
682
            vhosts_declared => [ declared_vhost_names($host) ],
683
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
684
            roles => [ @{ $host->{roles} || [] } ],
685
            monitoring => $host->{monitoring} || 'pending',
686
            notes => $host->{notes} || '',
687
        };
688
    }
689
    return {
690
        version => $registry->{version},
691
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
692
        source => $opt{db},
Xdev Host Manager authored a week ago
693
        hosts => \@hosts,
694
    };
695
}
696

            
Bogdan Timofte authored 4 days ago
697
sub debug_database_tables_payload {
698
    my $dbh = dbh();
699
    my @tables;
700
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
701
    $sth->execute;
702
    while (my ($name) = $sth->fetchrow_array) {
703
        my $quoted = $dbh->quote_identifier($name);
704
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
705
        push @tables, {
706
            name => $name,
707
            rows => int($count || 0),
708
        };
709
    }
710
    return {
711
        database => $opt{db},
712
        generated_at => iso_now(),
713
        tables => \@tables,
714
        counts => {
715
            tables => scalar @tables,
716
            rows => sum(map { $_->{rows} } @tables),
717
        },
718
    };
719
}
720

            
721
sub debug_database_table_payload {
722
    my ($table, $limit) = @_;
723
    my $dbh = dbh();
724
    $table = clean_scalar($table);
725
    return { error => 'missing_table' } unless length $table;
726
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
727
    $limit = int($limit || 100);
728
    $limit = 1 if $limit < 1;
729
    $limit = 500 if $limit > 500;
730

            
731
    my $quoted = $dbh->quote_identifier($table);
732
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
733
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
734
    my @index_details;
735
    for my $index (@$indexes) {
736
        my $index_name = $index->{name} || '';
737
        next unless length $index_name;
738
        my $quoted_index = $dbh->quote_identifier($index_name);
739
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
740
        push @index_details, {
741
            name => $index_name,
742
            unique => int($index->{unique} || 0),
743
            origin => $index->{origin} || '',
744
            partial => int($index->{partial} || 0),
745
            columns => [ map { $_->{name} || '' } @$index_columns ],
746
        };
747
    }
748
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
749
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
750
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
751

            
752
    return {
753
        database => $opt{db},
754
        table => $table,
755
        generated_at => iso_now(),
756
        limit => $limit,
757
        row_count => int($row_count || 0),
758
        columns => $columns,
759
        indexes => \@index_details,
760
        foreign_keys => $foreign_keys,
761
        rows => $rows,
762
    };
763
}
764

            
Bogdan Timofte authored 4 days ago
765
sub debug_database_table_export_payload {
766
    my ($table) = @_;
767
    my $dbh = dbh();
768
    $table = clean_scalar($table);
769
    return { error => 'missing_table' } unless length $table;
770
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
771

            
772
    my $quoted = $dbh->quote_identifier($table);
773
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
774
    my @column_names = map { $_->{name} || '' } @$columns;
775
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
776
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
777

            
778
    return {
779
        database => $opt{db},
780
        table => $table,
781
        generated_at => iso_now(),
782
        row_count => int($row_count || 0),
783
        columns => \@column_names,
784
        rows => $rows,
785
    };
786
}
787

            
788
sub render_debug_table_csv {
789
    my ($export) = @_;
790
    my @columns = @{ $export->{columns} || [] };
791
    my @lines = (join(',', map { csv_cell($_) } @columns));
792
    for my $row (@{ $export->{rows} || [] }) {
793
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
794
    }
795
    return join("\n", @lines) . "\n";
796
}
797

            
798
sub csv_cell {
799
    my ($value) = @_;
800
    $value = '' unless defined $value;
801
    $value = "$value";
802
    $value =~ s/"/""/g;
803
    return qq("$value") if $value =~ /[",\r\n]/;
804
    return $value;
805
}
806

            
807
sub debug_table_export_filename {
808
    my ($table, $extension) = @_;
809
    $table = clean_scalar($table || 'table');
810
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
811
    $table = 'table' unless length $table;
812
    return "debug-$table.$extension";
813
}
814

            
Bogdan Timofte authored 4 days ago
815
sub debug_table_exists {
816
    my ($dbh, $table) = @_;
817
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
818
    my ($exists) = $dbh->selectrow_array(
819
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
820
        undef,
821
        $table,
822
    );
823
    return $exists ? 1 : 0;
824
}
825

            
826
sub sum {
827
    my $total = 0;
828
    $total += $_ || 0 for @_;
829
    return $total;
830
}
831

            
Xdev Host Manager authored a week ago
832
sub ca_script_path {
833
    return "$project_dir/scripts/ca_manager.sh";
834
}
835

            
836
sub ca_dir {
837
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
838
}
839

            
840
sub ca_cert_path {
841
    return ca_dir() . "/certs/ca.cert.pem";
842
}
843

            
Bogdan Timofte authored 5 days ago
844
sub ca_issued_cert_path {
845
    my ($name) = @_;
846
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
847
    return ca_dir() . "/issued/$name.cert.pem";
848
}
849

            
Xdev Host Manager authored a week ago
850
sub ca_manager_json {
851
    my ($command) = @_;
852
    my $script = ca_script_path();
853
    die "CA manager script is missing\n" unless -x $script;
854
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
855
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
856
    local $/;
857
    my $out = <$fh>;
858
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
859
    $out ||= $command eq 'list-json' ? '[]' : '{}';
860
    sync_certificates_from_json($out) if $command eq 'list-json';
861
    return $out;
862
}
863

            
864
sub sync_certificates_from_json {
865
    my ($json) = @_;
866
    my $certs = eval { json_decode($json || '[]') };
867
    return if $@ || ref($certs) ne 'ARRAY';
868
    my $dbh = dbh();
869
    my $now = iso_now();
870
    with_transaction($dbh, sub {
871
        for my $cert (@$certs) {
872
            next unless ref($cert) eq 'HASH';
873
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
874
            next unless $name;
875
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
876
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
877
            my $cert_path = ca_issued_cert_path($name);
878
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
879
            my $serial = clean_scalar($cert->{serial} || '');
880
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
881
            $dbh->do(
882
                '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) '
883
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
884
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
885
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
886
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
887
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
888
                undef,
889
                $name,
890
                $host_fqdn || undef,
891
                $dns_names[0] || '',
892
                clean_scalar($cert->{subject} || ''),
893
                clean_scalar($cert->{issuer} || ''),
894
                length($serial) ? $serial : undef,
895
                clean_scalar($cert->{not_before} || ''),
896
                clean_scalar($cert->{not_after} || ''),
897
                length($fingerprint) ? $fingerprint : undef,
898
                $cert_path,
899
                $csr_path,
900
                $now,
901
                $now,
902
            );
903
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
904
            for my $dns_name (@dns_names) {
905
                next unless length $dns_name;
906
                $dbh->do(
907
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
908
                    undef,
909
                    $name,
910
                    $dns_name,
911
                );
912
            }
913
        }
914
    });
915
}
916

            
917
sub infer_certificate_host_fqdn {
918
    my ($dbh, $dns_names) = @_;
919
    for my $name (@$dns_names) {
920
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
921
        return $fqdn if $fqdn;
922
    }
923
    for my $name (@$dns_names) {
924
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
925
        return $fqdn if $fqdn;
926
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
927
        return $fqdn if $fqdn;
928
    }
929
    return '';
Xdev Host Manager authored a week ago
930
}
931

            
Xdev Host Manager authored a week ago
932
sub parse_hosts_yaml {
933
    my ($text) = @_;
934
    my %registry = (
935
        version => 1,
936
        updated_at => '',
937
        policy => {},
938
        hosts => [],
939
    );
940
    my ($section, $current, $list_key);
941
    for my $line (split /\n/, $text) {
942
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
943
        if ($line =~ /^version:\s*(\d+)/) {
944
            $registry{version} = int($1);
945
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
946
            $registry{updated_at} = yaml_unquote($1);
947
        } elsif ($line =~ /^policy:\s*$/) {
948
            $section = 'policy';
949
        } elsif ($line =~ /^hosts:\s*$/) {
950
            $section = 'hosts';
951
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
952
            $registry{policy}{$1} = yaml_unquote($2);
953
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
954
            $current = {
955
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
956
                fqdn => '',
Xdev Host Manager authored a week ago
957
                status => 'active',
Bogdan Timofte authored 4 days ago
958
                ip => '',
959
                aliases => [],
960
                vhosts => [],
Xdev Host Manager authored a week ago
961
                roles => [],
962
                sources => [],
963
                monitoring => 'pending',
964
                notes => '',
965
            };
966
            push @{ $registry{hosts} }, $current;
967
            $list_key = undef;
968
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
969
            $list_key = $1;
970
            $current->{$list_key} ||= [];
971
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
972
            push @{ $current->{$list_key} }, yaml_unquote($1);
973
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
974
            my $key = $1;
975
            my $value = yaml_unquote($2);
976
            if ($key eq 'ip') {
977
                $current->{ip} = $value;
978
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
979
                $current->{ip} ||= $value;
980
            } elsif ($key eq 'fqdn') {
981
                $current->{fqdn} = normalize_dns_name($value);
982
            } elsif ($key eq 'names') {
983
                # ignored here; legacy list is handled after parsing
984
            } else {
985
                $current->{$key} = $value;
986
            }
Xdev Host Manager authored a week ago
987
            $list_key = undef;
988
        }
989
    }
Bogdan Timofte authored 4 days ago
990
    for my $host (@{ $registry{hosts} }) {
991
        my @legacy_names = @{ $host->{names} || [] };
992
        if (@legacy_names) {
993
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
994
            $host->{fqdn} ||= $legacy->{fqdn};
995
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
996
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
997
        }
998
        delete $host->{names};
999
        $host->{fqdn} ||= canonical_host_fqdn($host);
1000
    }
Xdev Host Manager authored a week ago
1001
    return \%registry;
1002
}
1003

            
1004
sub render_hosts_yaml {
1005
    my ($registry) = @_;
1006
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1007
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1008
    $out .= "policy:\n";
1009
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1010
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1011
    }
1012
    $out .= "hosts:\n";
1013
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1014
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1015
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1016
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1017
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1018
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1019
            $out .= "    $key:\n";
1020
            for my $value (@{ $host->{$key} || [] }) {
1021
                $out .= "      - " . yq($value) . "\n";
1022
            }
1023
        }
1024
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1025
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1026
    }
1027
    return $out;
1028
}
1029

            
Xdev Host Manager authored a week ago
1030
sub parse_work_orders_yaml {
1031
    my ($text) = @_;
1032
    my %orders = (
1033
        version => 1,
1034
        work_orders => [],
1035
    );
Xdev Host Manager authored a week ago
1036
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1037
    for my $line (split /\n/, $text) {
1038
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1039
        if ($line =~ /^version:\s*(\d+)/) {
1040
            $orders{version} = int($1);
1041
        } elsif ($line =~ /^work_orders:\s*$/) {
1042
            $section = 'work_orders';
1043
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1044
            $current = {
1045
                id => yaml_unquote($1),
1046
                status => 'pending',
Xdev Host Manager authored a week ago
1047
                checklist => [],
Xdev Host Manager authored a week ago
1048
                actions => [],
1049
            };
1050
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1051
            $list_section = '';
Xdev Host Manager authored a week ago
1052
            $current_action = undef;
Xdev Host Manager authored a week ago
1053
            $current_item = undef;
1054
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1055
            $list_section = 'checklist';
1056
            $current->{checklist} ||= [];
1057
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1058
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1059
            push @{ $current->{checklist} }, $current_item;
1060
            $current_action = undef;
1061
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1062
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1063
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1064
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1065
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1066
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1067
            $current_action = { type => yaml_unquote($1) };
1068
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1069
            $current_item = undef;
1070
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1071
            $current_action->{$1} = yaml_unquote($2);
1072
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1073
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1074
            $list_section = '';
Xdev Host Manager authored a week ago
1075
            $current_action = undef;
Xdev Host Manager authored a week ago
1076
            $current_item = undef;
Xdev Host Manager authored a week ago
1077
        }
1078
    }
1079
    return \%orders;
1080
}
1081

            
1082
sub render_work_orders_yaml {
1083
    my ($orders) = @_;
1084
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1085
    $out .= "work_orders:\n";
1086
    for my $wo (@{ $orders->{work_orders} || [] }) {
1087
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1088
        for my $key (qw(status title reason created_at confirmed_at result)) {
1089
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1090
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1091
        }
Xdev Host Manager authored a week ago
1092
        $out .= "    checklist:\n";
1093
        for my $item (@{ $wo->{checklist} || [] }) {
1094
            $out .= "      - id: " . yq($item->{id}) . "\n";
1095
            for my $key (qw(text status owner notes updated_at)) {
1096
                next unless exists $item->{$key} && length($item->{$key} || '');
1097
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1098
            }
1099
        }
Xdev Host Manager authored a week ago
1100
        $out .= "    actions:\n";
1101
        for my $action (@{ $wo->{actions} || [] }) {
1102
            $out .= "      - type: " . yq($action->{type}) . "\n";
1103
            for my $key (qw(host_id name)) {
1104
                next unless exists $action->{$key} && length($action->{$key} || '');
1105
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1106
            }
1107
        }
1108
    }
1109
    return $out;
1110
}
1111

            
Xdev Host Manager authored a week ago
1112
sub request_payload {
1113
    my ($headers, $body) = @_;
1114
    my $type = $headers->{'content-type'} || '';
1115
    if ($type =~ m{application/json}) {
1116
        return json_decode($body || '{}');
1117
    }
1118
    return { parse_params($body || '') };
1119
}
1120

            
1121
sub json_bool {
1122
    my ($value) = @_;
1123
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1124
}
1125

            
1126
sub json_encode {
1127
    my ($value) = @_;
1128
    if (!defined $value) {
1129
        return 'null';
1130
    }
1131
    my $ref = ref($value);
1132
    if (!$ref) {
1133
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1134
        return json_string($value);
1135
    }
1136
    if ($ref eq 'HostManager::JSONBool') {
1137
        return $$value ? 'true' : 'false';
1138
    }
1139
    if ($ref eq 'ARRAY') {
1140
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1141
    }
1142
    if ($ref eq 'HASH') {
1143
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1144
    }
1145
    return json_string("$value");
1146
}
1147

            
1148
sub json_string {
1149
    my ($value) = @_;
1150
    $value = '' unless defined $value;
1151
    $value =~ s/\\/\\\\/g;
1152
    $value =~ s/"/\\"/g;
1153
    $value =~ s/\n/\\n/g;
1154
    $value =~ s/\r/\\r/g;
1155
    $value =~ s/\t/\\t/g;
1156
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1157
    return qq("$value");
1158
}
1159

            
1160
sub json_decode {
1161
    my ($text) = @_;
1162
    my $i = 0;
1163
    my $len = length($text);
1164
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1165

            
1166
    $skip_ws = sub {
1167
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1168
    };
1169

            
1170
    $parse_string = sub {
1171
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1172
        $i++;
1173
        my $out = '';
1174
        while ($i < $len) {
1175
            my $ch = substr($text, $i++, 1);
1176
            return $out if $ch eq '"';
1177
            if ($ch eq "\\") {
1178
                die "Bad JSON escape\n" if $i >= $len;
1179
                my $esc = substr($text, $i++, 1);
1180
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1181
                    $out .= $esc;
1182
                } elsif ($esc eq 'b') {
1183
                    $out .= "\b";
1184
                } elsif ($esc eq 'f') {
1185
                    $out .= "\f";
1186
                } elsif ($esc eq 'n') {
1187
                    $out .= "\n";
1188
                } elsif ($esc eq 'r') {
1189
                    $out .= "\r";
1190
                } elsif ($esc eq 't') {
1191
                    $out .= "\t";
1192
                } elsif ($esc eq 'u') {
1193
                    my $hex = substr($text, $i, 4);
1194
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1195
                    $out .= chr(hex($hex));
1196
                    $i += 4;
1197
                } else {
1198
                    die "Bad JSON escape\n";
1199
                }
1200
            } else {
1201
                $out .= $ch;
1202
            }
1203
        }
1204
        die "Unterminated JSON string\n";
1205
    };
1206

            
1207
    $parse_number = sub {
1208
        my $start = $i;
1209
        $i++ if substr($text, $i, 1) eq '-';
1210
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1211
        if ($i < $len && substr($text, $i, 1) eq '.') {
1212
            $i++;
1213
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1214
        }
1215
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1216
            $i++;
1217
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1218
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1219
        }
1220
        return 0 + substr($text, $start, $i - $start);
1221
    };
1222

            
1223
    $parse_array = sub {
1224
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1225
        $i++;
1226
        my @out;
1227
        $skip_ws->();
1228
        if ($i < $len && substr($text, $i, 1) eq ']') {
1229
            $i++;
1230
            return \@out;
1231
        }
1232
        while (1) {
1233
            push @out, $parse_value->();
1234
            $skip_ws->();
1235
            my $ch = substr($text, $i++, 1);
1236
            last if $ch eq ']';
1237
            die "Expected JSON array comma\n" unless $ch eq ',';
1238
        }
1239
        return \@out;
1240
    };
1241

            
1242
    $parse_object = sub {
1243
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1244
        $i++;
1245
        my %out;
1246
        $skip_ws->();
1247
        if ($i < $len && substr($text, $i, 1) eq '}') {
1248
            $i++;
1249
            return \%out;
1250
        }
1251
        while (1) {
1252
            $skip_ws->();
1253
            my $key = $parse_string->();
1254
            $skip_ws->();
1255
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1256
            $out{$key} = $parse_value->();
1257
            $skip_ws->();
1258
            my $ch = substr($text, $i++, 1);
1259
            last if $ch eq '}';
1260
            die "Expected JSON object comma\n" unless $ch eq ',';
1261
        }
1262
        return \%out;
1263
    };
1264

            
1265
    $parse_value = sub {
1266
        $skip_ws->();
1267
        die "Unexpected end of JSON\n" if $i >= $len;
1268
        my $ch = substr($text, $i, 1);
1269
        return $parse_string->() if $ch eq '"';
1270
        return $parse_object->() if $ch eq '{';
1271
        return $parse_array->() if $ch eq '[';
1272
        if (substr($text, $i, 4) eq 'true') {
1273
            $i += 4;
1274
            return json_bool(1);
1275
        }
1276
        if (substr($text, $i, 5) eq 'false') {
1277
            $i += 5;
1278
            return json_bool(0);
1279
        }
1280
        if (substr($text, $i, 4) eq 'null') {
1281
            $i += 4;
1282
            return undef;
1283
        }
1284
        return $parse_number->() if $ch =~ /[-0-9]/;
1285
        die "Unexpected JSON token\n";
1286
    };
1287

            
1288
    my $value = $parse_value->();
1289
    $skip_ws->();
1290
    die "Trailing JSON content\n" if $i != $len;
1291
    return $value;
1292
}
1293

            
1294
sub parse_params {
1295
    my ($text) = @_;
1296
    my %out;
1297
    for my $pair (split /&/, $text) {
1298
        next unless length $pair;
1299
        my ($k, $v) = split /=/, $pair, 2;
1300
        $out{url_decode($k)} = url_decode($v || '');
1301
    }
1302
    return %out;
1303
}
1304

            
1305
sub clean_id {
1306
    my ($value) = @_;
1307
    $value = lc clean_scalar($value);
1308
    $value =~ s/[^a-z0-9_.-]+/-/g;
1309
    $value =~ s/^-+|-+$//g;
1310
    return $value;
1311
}
1312

            
1313
sub clean_scalar {
1314
    my ($value) = @_;
1315
    $value = '' unless defined $value;
1316
    $value =~ s/[\r\n\t]+/ /g;
1317
    $value =~ s/^\s+|\s+$//g;
1318
    return $value;
1319
}
1320

            
1321
sub clean_list {
1322
    my ($value) = @_;
1323
    return () unless defined $value;
1324
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1325
    my @clean;
1326
    for my $item (@items) {
1327
        $item = clean_scalar($item);
1328
        push @clean, $item if length $item;
1329
    }
1330
    return @clean;
1331
}
1332

            
1333
sub yq {
1334
    my ($value) = @_;
1335
    $value = '' unless defined $value;
1336
    $value =~ s/\\/\\\\/g;
1337
    $value =~ s/"/\\"/g;
1338
    return qq("$value");
1339
}
1340

            
1341
sub yaml_unquote {
1342
    my ($value) = @_;
1343
    $value = '' unless defined $value;
1344
    $value =~ s/^\s+|\s+$//g;
1345
    if ($value =~ /^"(.*)"$/) {
1346
        $value = $1;
1347
        $value =~ s/\\"/"/g;
1348
        $value =~ s/\\\\/\\/g;
1349
    }
1350
    return $value;
1351
}
1352

            
1353
sub verify_totp {
1354
    my ($secret, $otp) = @_;
1355
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1356
    my $key = eval { base32_decode($secret) };
1357
    return 0 if $@ || !length $key;
1358
    my $counter = int(time() / 30);
1359
    for my $offset (-1, 0, 1) {
1360
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1361
    }
1362
    return 0;
1363
}
1364

            
1365
sub totp_code {
1366
    my ($key, $counter) = @_;
1367
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1368
    my $hash = hmac_sha1($msg, $key);
1369
    my $offset = ord(substr($hash, -1)) & 0x0f;
1370
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1371
    return sprintf('%06d', $bin % 1_000_000);
1372
}
1373

            
1374
sub base32_decode {
1375
    my ($text) = @_;
1376
    $text = uc($text || '');
1377
    $text =~ s/[^A-Z2-7]//g;
1378
    my %map;
1379
    my @chars = ('A'..'Z', '2'..'7');
1380
    @map{@chars} = (0..31);
1381
    my ($bits, $value, $out) = (0, 0, '');
1382
    for my $char (split //, $text) {
1383
        die "Invalid base32\n" unless exists $map{$char};
1384
        $value = ($value << 5) | $map{$char};
1385
        $bits += 5;
1386
        while ($bits >= 8) {
1387
            $bits -= 8;
1388
            $out .= chr(($value >> $bits) & 0xff);
1389
        }
1390
    }
1391
    return $out;
1392
}
1393

            
1394
sub create_session {
1395
    my $nonce = random_hex(24);
1396
    my $expires = int(time() + 8 * 3600);
1397
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1398
    my $token = "$nonce:$expires:$sig";
1399
    $sessions{$token} = $expires;
1400
    return $token;
1401
}
1402

            
1403
sub is_authenticated {
1404
    my ($headers) = @_;
1405
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1406
    return 0 unless $token;
1407
    my ($nonce, $expires, $sig) = split /:/, $token;
1408
    return 0 unless $nonce && $expires && $sig;
1409
    return 0 if $expires < time();
1410
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1411
    return exists $sessions{$token};
1412
}
1413

            
1414
sub expire_session {
1415
    my ($headers) = @_;
1416
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1417
    delete $sessions{$token} if $token;
1418
}
1419

            
1420
sub cookie_value {
1421
    my ($cookie, $name) = @_;
1422
    for my $part (split /;\s*/, $cookie) {
1423
        my ($k, $v) = split /=/, $part, 2;
1424
        return $v if defined $k && $k eq $name;
1425
    }
1426
    return '';
1427
}
1428

            
1429
sub send_json {
1430
    my ($client, $status, $payload, $extra_headers) = @_;
1431
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1432
}
1433

            
Xdev Host Manager authored a week ago
1434
sub send_json_raw {
1435
    my ($client, $status, $json_body, $extra_headers) = @_;
1436
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1437
}
1438

            
Xdev Host Manager authored a week ago
1439
sub send_html {
1440
    my ($client, $status, $html) = @_;
1441
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1442
}
1443

            
1444
sub send_text {
1445
    my ($client, $status, $text) = @_;
1446
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1447
}
1448

            
1449
sub send_download {
1450
    my ($client, $status, $content, $type, $filename) = @_;
1451
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1452
}
1453

            
1454
sub send_file {
1455
    my ($client, $path, $type, $filename) = @_;
1456
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1457
    return send_download($client, 200, read_file($path), $type, $filename);
1458
}
1459

            
1460
sub send_response {
1461
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1462
    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
1463
    $body = '' unless defined $body;
1464
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1465
    print $client "Content-Type: $type\r\n";
1466
    print $client "Content-Length: " . length($body) . "\r\n";
1467
    print $client "Cache-Control: no-store\r\n";
1468
    print $client "$_\r\n" for @{ $extra_headers || [] };
1469
    print $client "Connection: close\r\n\r\n";
1470
    print $client $body;
1471
}
1472

            
1473
sub read_file {
1474
    my ($path) = @_;
1475
    open my $fh, '<', $path or die "Cannot read $path: $!";
1476
    local $/;
1477
    return <$fh>;
1478
}
1479

            
1480
sub write_file {
1481
    my ($path, $content) = @_;
1482
    open my $fh, '>', $path or die "Cannot write $path: $!";
1483
    print {$fh} $content;
1484
    close $fh or die "Cannot close $path: $!";
1485
}
1486

            
1487
sub backup_file {
1488
    my ($path) = @_;
1489
    return unless -f $path;
1490
    my $backup_dir = "$project_dir/backups/host-manager";
1491
    make_path($backup_dir) unless -d $backup_dir;
1492
    my $name = $path;
1493
    $name =~ s{.*/}{};
1494
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1495
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1496
}
1497

            
Bogdan Timofte authored 4 days ago
1498
my $db_handle;
Bogdan Timofte authored 4 days ago
1499
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1500

            
1501
sub dbh {
1502
    return $db_handle if $db_handle;
1503
    ensure_parent_dir($opt{db});
1504
    $db_handle = DBI->connect(
1505
        "dbi:SQLite:dbname=$opt{db}",
1506
        '',
1507
        '',
1508
        {
1509
            RaiseError => 1,
1510
            PrintError => 0,
1511
            AutoCommit => 1,
1512
            sqlite_unicode => 1,
1513
        },
1514
    ) or die "Cannot open SQLite database $opt{db}\n";
1515
    $db_handle->do('PRAGMA journal_mode = WAL');
1516
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1517
    create_database_schema($db_handle);
1518
    seed_database($db_handle) unless $db_seeded++;
1519
    return $db_handle;
1520
}
1521

            
1522
sub create_database_schema {
1523
    my ($dbh) = @_;
1524
    $dbh->do(<<'SQL');
1525
CREATE TABLE IF NOT EXISTS schema_meta (
1526
    key TEXT PRIMARY KEY,
1527
    value TEXT NOT NULL,
1528
    updated_at TEXT NOT NULL
1529
)
1530
SQL
1531
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1532
CREATE TABLE IF NOT EXISTS documents (
1533
    name TEXT PRIMARY KEY,
1534
    content TEXT NOT NULL,
1535
    updated_at TEXT NOT NULL
1536
)
1537
SQL
Bogdan Timofte authored 4 days ago
1538
    $dbh->do(
1539
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1540
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1541
        undef, 'schema_version', '2', iso_now()
1542
    );
1543
    $dbh->do(<<'SQL');
1544
CREATE TABLE IF NOT EXISTS hosts (
1545
    fqdn TEXT PRIMARY KEY,
1546
    legacy_id TEXT NOT NULL UNIQUE,
1547
    status TEXT NOT NULL DEFAULT 'active',
1548
    hosts_ip TEXT NOT NULL DEFAULT '',
1549
    dns_ip TEXT NOT NULL DEFAULT '',
1550
    monitoring TEXT NOT NULL DEFAULT 'pending',
1551
    notes TEXT NOT NULL DEFAULT '',
1552
    created_at TEXT NOT NULL,
1553
    updated_at TEXT NOT NULL
1554
)
1555
SQL
1556
    $dbh->do(<<'SQL');
1557
CREATE TABLE IF NOT EXISTS host_aliases (
1558
    alias_name TEXT NOT NULL,
1559
    host_fqdn TEXT NOT NULL,
1560
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1561
    status TEXT NOT NULL DEFAULT 'active',
1562
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1563
    created_at TEXT NOT NULL,
1564
    retired_at TEXT,
1565
    notes TEXT NOT NULL DEFAULT '',
1566
    PRIMARY KEY (alias_name, host_fqdn),
1567
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1568
)
1569
SQL
1570
    $dbh->do(<<'SQL');
1571
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1572
ON host_aliases(alias_name)
1573
WHERE status = 'active'
1574
SQL
1575
    $dbh->do(<<'SQL');
1576
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1577
ON host_aliases(host_fqdn, status)
1578
SQL
1579
    $dbh->do(<<'SQL');
1580
CREATE TABLE IF NOT EXISTS host_roles (
1581
    host_fqdn TEXT NOT NULL,
1582
    role TEXT NOT NULL,
1583
    status TEXT NOT NULL DEFAULT 'active',
1584
    created_at TEXT NOT NULL,
1585
    retired_at TEXT,
1586
    PRIMARY KEY (host_fqdn, role),
1587
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1588
)
1589
SQL
1590
    $dbh->do(<<'SQL');
1591
CREATE TABLE IF NOT EXISTS host_sources (
1592
    host_fqdn TEXT NOT NULL,
1593
    source TEXT NOT NULL,
1594
    status TEXT NOT NULL DEFAULT 'active',
1595
    created_at TEXT NOT NULL,
1596
    retired_at TEXT,
1597
    PRIMARY KEY (host_fqdn, source),
1598
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1599
)
1600
SQL
1601
    $dbh->do(<<'SQL');
1602
CREATE TABLE IF NOT EXISTS host_flags (
1603
    host_fqdn TEXT NOT NULL,
1604
    flag TEXT NOT NULL,
1605
    value TEXT NOT NULL DEFAULT '1',
1606
    created_at TEXT NOT NULL,
1607
    updated_at TEXT NOT NULL,
1608
    PRIMARY KEY (host_fqdn, flag),
1609
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1610
)
1611
SQL
1612
    $dbh->do(<<'SQL');
1613
CREATE TABLE IF NOT EXISTS host_ssh (
1614
    host_fqdn TEXT NOT NULL,
1615
    profile_name TEXT NOT NULL DEFAULT 'default',
1616
    username TEXT NOT NULL DEFAULT '',
1617
    port INTEGER NOT NULL DEFAULT 22,
1618
    identity_file TEXT NOT NULL DEFAULT '',
1619
    address TEXT NOT NULL DEFAULT '',
1620
    local_forward_host TEXT NOT NULL DEFAULT '',
1621
    local_forward_port INTEGER,
1622
    remote_forward_host TEXT NOT NULL DEFAULT '',
1623
    remote_forward_port INTEGER,
1624
    notes TEXT NOT NULL DEFAULT '',
1625
    created_at TEXT NOT NULL,
1626
    updated_at TEXT NOT NULL,
1627
    PRIMARY KEY (host_fqdn, profile_name),
1628
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1629
)
1630
SQL
1631
    $dbh->do(<<'SQL');
1632
CREATE TABLE IF NOT EXISTS certificates (
1633
    certificate_id TEXT PRIMARY KEY,
1634
    host_fqdn TEXT,
1635
    common_name TEXT NOT NULL DEFAULT '',
1636
    subject TEXT NOT NULL DEFAULT '',
1637
    issuer TEXT NOT NULL DEFAULT '',
1638
    serial TEXT UNIQUE,
1639
    status TEXT NOT NULL DEFAULT 'issued',
1640
    not_before TEXT NOT NULL DEFAULT '',
1641
    not_after TEXT NOT NULL DEFAULT '',
1642
    fingerprint_sha256 TEXT UNIQUE,
1643
    cert_path TEXT NOT NULL DEFAULT '',
1644
    csr_path TEXT NOT NULL DEFAULT '',
1645
    created_at TEXT NOT NULL,
1646
    updated_at TEXT NOT NULL,
1647
    notes TEXT NOT NULL DEFAULT '',
1648
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1649
)
1650
SQL
1651
    $dbh->do(<<'SQL');
1652
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1653
    certificate_id TEXT NOT NULL,
1654
    dns_name TEXT NOT NULL,
1655
    PRIMARY KEY (certificate_id, dns_name),
1656
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1657
)
1658
SQL
1659
    $dbh->do(<<'SQL');
1660
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1661
ON certificate_dns_names(dns_name)
1662
SQL
1663
    $dbh->do(<<'SQL');
1664
CREATE TABLE IF NOT EXISTS vhosts (
1665
    vhost_fqdn TEXT PRIMARY KEY,
1666
    host_fqdn TEXT NOT NULL,
1667
    status TEXT NOT NULL DEFAULT 'active',
1668
    service_name TEXT NOT NULL DEFAULT '',
1669
    upstream_url TEXT NOT NULL DEFAULT '',
1670
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1671
    certificate_id TEXT,
1672
    notes TEXT NOT NULL DEFAULT '',
1673
    created_at TEXT NOT NULL,
1674
    updated_at TEXT NOT NULL,
1675
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1676
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1677
)
1678
SQL
1679
    $dbh->do(<<'SQL');
1680
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1681
ON vhosts(host_fqdn, status)
1682
SQL
1683
    $dbh->do(<<'SQL');
1684
CREATE TABLE IF NOT EXISTS data_workers (
1685
    worker_id TEXT PRIMARY KEY,
1686
    worker_type TEXT NOT NULL,
1687
    name TEXT NOT NULL DEFAULT '',
1688
    status TEXT NOT NULL DEFAULT 'active',
1689
    source TEXT NOT NULL DEFAULT '',
1690
    last_run_at TEXT,
1691
    notes TEXT NOT NULL DEFAULT '',
1692
    created_at TEXT NOT NULL,
1693
    updated_at TEXT NOT NULL
1694
)
1695
SQL
1696
    $dbh->do(<<'SQL');
1697
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1698
ON data_workers(worker_type, status)
1699
SQL
1700
    $dbh->do(<<'SQL');
1701
CREATE TABLE IF NOT EXISTS dhcp_leases (
1702
    lease_key TEXT PRIMARY KEY,
1703
    worker_id TEXT NOT NULL,
1704
    host_fqdn TEXT,
1705
    observed_name TEXT NOT NULL DEFAULT '',
1706
    ip_address TEXT NOT NULL,
1707
    mac_address TEXT NOT NULL DEFAULT '',
1708
    lease_state TEXT NOT NULL DEFAULT '',
1709
    first_seen TEXT NOT NULL,
1710
    last_seen TEXT NOT NULL,
1711
    raw TEXT NOT NULL DEFAULT '',
1712
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1713
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1714
)
1715
SQL
1716
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1717
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1718
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1719
    $dbh->do(<<'SQL');
1720
CREATE TABLE IF NOT EXISTS mdns_observations (
1721
    observation_key TEXT PRIMARY KEY,
1722
    worker_id TEXT NOT NULL,
1723
    host_fqdn TEXT,
1724
    observed_name TEXT NOT NULL,
1725
    ip_address TEXT NOT NULL,
1726
    rr_type TEXT NOT NULL DEFAULT 'A',
1727
    ttl INTEGER NOT NULL DEFAULT 0,
1728
    first_seen TEXT NOT NULL,
1729
    last_seen TEXT NOT NULL,
1730
    seen_count INTEGER NOT NULL DEFAULT 1,
1731
    last_peer TEXT NOT NULL DEFAULT '',
1732
    raw TEXT NOT NULL DEFAULT '',
1733
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1734
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1735
)
1736
SQL
1737
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1738
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1739
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1740
    $dbh->do(<<'SQL');
1741
CREATE TABLE IF NOT EXISTS work_orders (
1742
    id TEXT PRIMARY KEY,
1743
    status TEXT NOT NULL DEFAULT 'pending',
1744
    title TEXT NOT NULL DEFAULT '',
1745
    reason TEXT NOT NULL DEFAULT '',
1746
    created_at TEXT NOT NULL,
1747
    confirmed_at TEXT NOT NULL DEFAULT '',
1748
    result TEXT NOT NULL DEFAULT '',
1749
    updated_at TEXT NOT NULL
1750
)
1751
SQL
1752
    $dbh->do(<<'SQL');
1753
CREATE TABLE IF NOT EXISTS work_order_checklist (
1754
    work_order_id TEXT NOT NULL,
1755
    item_id TEXT NOT NULL,
1756
    text TEXT NOT NULL DEFAULT '',
1757
    status TEXT NOT NULL DEFAULT 'pending',
1758
    owner TEXT NOT NULL DEFAULT '',
1759
    notes TEXT NOT NULL DEFAULT '',
1760
    updated_at TEXT NOT NULL DEFAULT '',
1761
    PRIMARY KEY (work_order_id, item_id),
1762
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1763
)
1764
SQL
1765
    $dbh->do(<<'SQL');
1766
CREATE TABLE IF NOT EXISTS work_order_actions (
1767
    work_order_id TEXT NOT NULL,
1768
    position INTEGER NOT NULL,
1769
    type TEXT NOT NULL,
1770
    host_fqdn TEXT,
1771
    host_legacy_id TEXT NOT NULL DEFAULT '',
1772
    name TEXT NOT NULL DEFAULT '',
1773
    payload TEXT NOT NULL DEFAULT '',
1774
    PRIMARY KEY (work_order_id, position),
1775
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1776
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1777
)
1778
SQL
Bogdan Timofte authored 4 days ago
1779
}
1780

            
Bogdan Timofte authored 4 days ago
1781
sub seed_database {
1782
    my ($dbh) = @_;
1783
    seed_default_workers($dbh);
1784

            
1785
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1786
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1787
        normalize_registry_policy($registry);
1788
        with_transaction($dbh, sub {
1789
            import_registry_to_db($dbh, $registry, 0);
1790
        });
1791
    }
1792

            
1793
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1794
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1795
        with_transaction($dbh, sub {
1796
            import_work_orders_to_db($dbh, $orders);
1797
        });
1798
    }
1799

            
1800
    seed_mdns_observations_from_yaml($dbh);
1801
}
1802

            
1803
sub with_transaction {
1804
    my ($dbh, $code) = @_;
1805
    return $code->() unless $dbh->{AutoCommit};
1806
    $dbh->begin_work;
1807
    my $ok = eval {
1808
        $code->();
1809
        1;
1810
    };
1811
    if (!$ok) {
1812
        my $err = $@ || 'transaction failed';
1813
        eval { $dbh->rollback };
1814
        die $err;
1815
    }
1816
    $dbh->commit;
1817
}
1818

            
1819
sub db_scalar {
1820
    my ($dbh, $sql, @bind) = @_;
1821
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1822
    return $value || 0;
1823
}
1824

            
1825
sub legacy_document_text {
1826
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1827
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1828
    return $row->{content} if $row && defined $row->{content};
1829
    return read_file($seed_path) if -f $seed_path;
1830
    return $default_text;
1831
}
1832

            
1833
sub load_registry_from_db {
1834
    my $dbh = dbh();
1835
    my $registry = {
1836
        version => 1,
1837
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1838
        policy => {},
1839
        hosts => [],
1840
    };
Bogdan Timofte authored 4 days ago
1841

            
Bogdan Timofte authored 4 days ago
1842
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1843
    $sth->execute;
1844
    while (my $row = $sth->fetchrow_hashref) {
1845
        my $fqdn = $row->{fqdn};
1846
        push @{ $registry->{hosts} }, {
1847
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
1848
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
1849
            status => $row->{status},
Bogdan Timofte authored 4 days ago
1850
            ip => canonical_ip($row),
1851
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
1852
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
1853
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1854
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1855
            monitoring => $row->{monitoring},
1856
            notes => $row->{notes},
1857
        };
1858
    }
1859

            
1860
    return $registry;
Bogdan Timofte authored 4 days ago
1861
}
1862

            
Bogdan Timofte authored 4 days ago
1863
sub save_registry_to_db {
1864
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1865
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1866
    with_transaction($dbh, sub {
1867
        import_registry_to_db($dbh, $registry, 1);
1868
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1869
    });
1870
}
1871

            
1872
sub import_registry_to_db {
1873
    my ($dbh, $registry, $retire_missing) = @_;
1874
    my %seen;
1875
    for my $host (@{ $registry->{hosts} || [] }) {
1876
        my $fqdn = upsert_host_to_db($dbh, $host);
1877
        $seen{$fqdn} = 1 if $fqdn;
1878
    }
1879

            
1880
    return unless $retire_missing;
1881
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1882
    $sth->execute('retired');
1883
    while (my ($fqdn) = $sth->fetchrow_array) {
1884
        next if $seen{$fqdn};
1885
        retire_host_in_db($dbh, $fqdn);
1886
    }
1887
}
1888

            
1889
sub upsert_host_to_db {
1890
    my ($dbh, $host) = @_;
1891
    my $now = iso_now();
1892
    my $fqdn = canonical_host_fqdn($host);
1893
    return '' unless $fqdn;
1894
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1895
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
1896
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
1897
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1898
    my $notes = clean_scalar($host->{notes} || '');
1899

            
Bogdan Timofte authored 4 days ago
1900
    $dbh->do(
Bogdan Timofte authored 4 days ago
1901
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1902
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1903
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1904
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1905
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1906
        undef,
Bogdan Timofte authored 4 days ago
1907
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
1908
    );
1909

            
1910
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1911
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
1912
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
1913
    return $fqdn;
1914
}
1915

            
1916
sub sync_host_values {
1917
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1918
    my $now = iso_now();
1919
    my %active = map { $_ => 1 } @$values;
1920
    for my $value (@$values) {
1921
        $dbh->do(
1922
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1923
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1924
            undef,
1925
            $fqdn, $value, $now,
1926
        );
1927
    }
1928

            
1929
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1930
    $sth->execute($fqdn);
1931
    while (my ($value) = $sth->fetchrow_array) {
1932
        next if $active{$value};
1933
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1934
    }
1935
}
1936

            
Bogdan Timofte authored 4 days ago
1937
sub sync_host_aliases_and_vhosts {
1938
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
1939
    my $now = iso_now();
1940
    my (%aliases, %vhosts);
1941
    if (my $short = short_alias_for_fqdn($fqdn)) {
1942
        $aliases{$short} = 1;
1943
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1944
    }
Bogdan Timofte authored 4 days ago
1945
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
1946
        $name = normalize_dns_name($name);
1947
        next unless length $name;
1948
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
1949
        $aliases{$name} = 1;
1950
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1951
        if (my $short = short_alias_for_fqdn($name)) {
1952
            $aliases{$short} = 1;
1953
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1954
        }
1955
    }
1956
    for my $name (@$vhosts_in) {
1957
        $name = normalize_dns_name($name);
1958
        next unless length $name;
1959
        $vhosts{$name} = 1;
1960
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1961
        if (my $short = short_alias_for_fqdn($name)) {
1962
            $aliases{$short} = 1;
1963
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
1964
        }
1965
    }
1966

            
1967
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1968
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1969
}
1970

            
1971
sub upsert_alias_to_db {
1972
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
1973
    my ($existing_fqdn) = $dbh->selectrow_array(
1974
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
1975
        undef,
1976
        $alias,
1977
    );
1978
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
1979
        if ($kind eq 'derived-vhost') {
1980
            $dbh->do(
1981
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
1982
                undef,
1983
                $now, $alias, $existing_fqdn,
1984
            );
1985
        } else {
1986
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
1987
        }
1988
    }
Bogdan Timofte authored 4 days ago
1989
    $dbh->do(
1990
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1991
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1992
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1993
        undef,
1994
        $alias, $fqdn, $kind, $now,
1995
    );
1996
}
1997

            
1998
sub upsert_vhost_to_db {
1999
    my ($dbh, $fqdn, $vhost, $now) = @_;
2000
    my $service = vhost_service_name($vhost);
2001
    $dbh->do(
2002
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2003
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2004
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2005
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2006
        undef,
2007
        $vhost, $fqdn, $service, $now, $now,
2008
    );
2009
}
2010

            
2011
sub retire_missing_names {
2012
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2013
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2014
    $sth->execute($fqdn);
2015
    while (my ($name) = $sth->fetchrow_array) {
2016
        next if $active->{$name};
2017
        if ($table eq 'host_aliases') {
2018
            $dbh->do(
2019
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2020
                undef, $now, $fqdn, $name,
2021
            );
2022
        } else {
2023
            $dbh->do(
2024
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2025
                undef, $now, $fqdn, $name,
2026
            );
2027
        }
2028
    }
2029
}
2030

            
2031
sub retire_host_in_db {
2032
    my ($dbh, $fqdn) = @_;
2033
    my $now = iso_now();
2034
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2035
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2036
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2037
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2038
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2039
}
2040

            
Bogdan Timofte authored 4 days ago
2041
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2042
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2043
    my @names;
Bogdan Timofte authored 4 days ago
2044
    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");
2045
    $aliases->execute($fqdn);
2046
    while (my ($name) = $aliases->fetchrow_array) {
2047
        push @names, $name;
2048
    }
Bogdan Timofte authored 4 days ago
2049
    return unique_preserve(@names);
2050
}
2051

            
2052
sub active_vhosts_for_host {
2053
    my ($dbh, $fqdn) = @_;
2054
    my @names;
Bogdan Timofte authored 4 days ago
2055
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2056
    $vhosts->execute($fqdn);
2057
    while (my ($name) = $vhosts->fetchrow_array) {
2058
        push @names, $name;
2059
    }
2060
    return unique_preserve(@names);
2061
}
2062

            
2063
sub active_values_for_host {
2064
    my ($dbh, $table, $column, $fqdn) = @_;
2065
    my @values;
2066
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2067
    $sth->execute($fqdn);
2068
    while (my ($value) = $sth->fetchrow_array) {
2069
        push @values, $value;
2070
    }
2071
    return @values;
2072
}
2073

            
2074
sub load_work_orders_from_db {
2075
    my $dbh = dbh();
2076
    my $orders = { version => 1, work_orders => [] };
2077
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2078
    $sth->execute;
2079
    while (my $row = $sth->fetchrow_hashref) {
2080
        my $wo = {
2081
            id => $row->{id},
2082
            status => $row->{status},
2083
            title => $row->{title},
2084
            reason => $row->{reason},
2085
            created_at => $row->{created_at},
2086
            checklist => [],
2087
            actions => [],
2088
        };
2089
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2090
        $wo->{result} = $row->{result} if length($row->{result} || '');
2091

            
2092
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2093
        $items->execute($row->{id});
2094
        while (my $item = $items->fetchrow_hashref) {
2095
            my %copy = (
2096
                id => $item->{item_id},
2097
                text => $item->{text},
2098
                status => $item->{status},
2099
            );
2100
            for my $key (qw(owner notes updated_at)) {
2101
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2102
            }
2103
            push @{ $wo->{checklist} }, \%copy;
2104
        }
2105

            
2106
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2107
        $actions->execute($row->{id});
2108
        while (my $action = $actions->fetchrow_hashref) {
2109
            my %copy = ( type => $action->{type} );
2110
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2111
            $copy{name} = $action->{name} if length($action->{name} || '');
2112
            push @{ $wo->{actions} }, \%copy;
2113
        }
2114

            
2115
        push @{ $orders->{work_orders} }, $wo;
2116
    }
2117
    return $orders;
2118
}
2119

            
2120
sub save_work_orders_to_db {
2121
    my ($orders) = @_;
2122
    my $dbh = dbh();
2123
    with_transaction($dbh, sub {
2124
        import_work_orders_to_db($dbh, $orders);
2125
    });
2126
}
2127

            
2128
sub import_work_orders_to_db {
2129
    my ($dbh, $orders) = @_;
2130
    my $now = iso_now();
2131
    my %seen;
2132
    for my $wo (@{ $orders->{work_orders} || [] }) {
2133
        my $id = clean_scalar($wo->{id} || '');
2134
        next unless $id;
2135
        $seen{$id} = 1;
2136
        $dbh->do(
2137
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2138
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2139
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2140
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2141
            undef,
2142
            $id,
2143
            clean_scalar($wo->{status} || 'pending'),
2144
            clean_scalar($wo->{title} || ''),
2145
            clean_scalar($wo->{reason} || ''),
2146
            clean_scalar($wo->{created_at} || $now),
2147
            clean_scalar($wo->{confirmed_at} || ''),
2148
            clean_scalar($wo->{result} || ''),
2149
            $now,
2150
        );
2151
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2152
        for my $item (@{ $wo->{checklist} || [] }) {
2153
            $dbh->do(
2154
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2155
                undef,
2156
                $id,
2157
                clean_scalar($item->{id} || ''),
2158
                clean_scalar($item->{text} || ''),
2159
                clean_scalar($item->{status} || 'pending'),
2160
                clean_scalar($item->{owner} || ''),
2161
                clean_scalar($item->{notes} || ''),
2162
                clean_scalar($item->{updated_at} || ''),
2163
            );
2164
        }
2165
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2166
        my $position = 0;
2167
        for my $action (@{ $wo->{actions} || [] }) {
2168
            my $legacy_id = clean_id($action->{host_id} || '');
2169
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2170
            $dbh->do(
2171
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2172
                undef,
2173
                $id,
2174
                $position++,
2175
                clean_scalar($action->{type} || ''),
2176
                $host_fqdn || undef,
2177
                $legacy_id,
2178
                normalize_dns_name($action->{name} || ''),
2179
                '',
2180
            );
2181
        }
2182
    }
2183
}
2184

            
2185
sub seed_default_workers {
2186
    my ($dbh) = @_;
2187
    my $now = iso_now();
2188
    my @workers = (
2189
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2190
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2191
    );
2192
    for my $worker (@workers) {
2193
        $dbh->do(
2194
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2195
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2196
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2197
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2198
            undef,
2199
            @$worker,
2200
            $now,
2201
            $now,
2202
        );
2203
    }
2204
}
2205

            
2206
sub seed_mdns_observations_from_yaml {
2207
    my ($dbh) = @_;
2208
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2209
    my $path = "$project_dir/var/mdns-observations.yaml";
2210
    return unless -f $path;
2211
    my $db = parse_mdns_observations_yaml(read_file($path));
2212
    with_transaction($dbh, sub {
2213
        for my $observation (@{ $db->{observations} || [] }) {
2214
            $dbh->do(
2215
                '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) '
2216
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2217
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2218
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2219
                undef,
2220
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2221
                clean_scalar($observation->{name} || ''),
2222
                clean_scalar($observation->{ip} || ''),
2223
                int($observation->{ttl} || 0),
2224
                clean_scalar($observation->{first_seen} || iso_now()),
2225
                clean_scalar($observation->{last_seen} || iso_now()),
2226
                int($observation->{seen_count} || 1),
2227
                clean_scalar($observation->{last_peer} || ''),
2228
            );
2229
        }
2230
    });
2231
}
2232

            
2233
sub parse_mdns_observations_yaml {
2234
    my ($text) = @_;
2235
    my %db = ( observations => [] );
2236
    my ($section, $current);
2237
    for my $line (split /\n/, $text || '') {
2238
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2239
        if ($line =~ /^observations:\s*$/) {
2240
            $section = 'observations';
2241
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2242
            $current = { key => yaml_unquote($1) };
2243
            push @{ $db{observations} }, $current;
2244
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2245
            $current->{$1} = yaml_unquote($2);
2246
        }
2247
    }
2248
    return \%db;
2249
}
2250

            
2251
sub set_schema_meta {
2252
    my ($dbh, $key, $value) = @_;
2253
    $dbh->do(
2254
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2255
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2256
        undef,
2257
        $key,
2258
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2259
        iso_now(),
2260
    );
2261
}
2262

            
Bogdan Timofte authored 4 days ago
2263
sub fqdn_for_legacy_id {
2264
    my ($dbh, $legacy_id) = @_;
2265
    return '' unless length($legacy_id || '');
2266
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2267
    return $fqdn || '';
2268
}
2269

            
2270
sub canonical_host_fqdn {
2271
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2272
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2273
    return $fqdn if length $fqdn;
2274
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2275
    for my $name (@names) {
2276
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2277
    }
2278
    for my $name (@names) {
2279
        return $name if $name =~ /\./ && !name_is_vhost($name);
2280
    }
2281
    my $id = clean_id($host->{id} || '');
2282
    return $id ? "$id.madagascar.xdev.ro" : '';
2283
}
2284

            
2285
sub legacy_id_from_fqdn {
2286
    my ($fqdn) = @_;
2287
    $fqdn = normalize_dns_name($fqdn);
2288
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2289
    $fqdn =~ s/\..*\z//;
2290
    return clean_id($fqdn);
2291
}
2292

            
2293
sub normalize_dns_name {
2294
    my ($name) = @_;
2295
    $name = lc clean_scalar($name || '');
2296
    $name =~ s/\.\z//;
2297
    return $name;
2298
}
2299

            
2300
sub name_is_vhost {
2301
    my ($name) = @_;
2302
    $name = normalize_dns_name($name);
2303
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2304
}
2305

            
2306
sub vhost_service_name {
2307
    my ($name) = @_;
2308
    $name = normalize_dns_name($name);
2309
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2310
    return '';
2311
}
2312

            
2313
sub short_alias_for_fqdn {
2314
    my ($name) = @_;
2315
    $name = normalize_dns_name($name);
2316
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2317
    return '';
2318
}
2319

            
Bogdan Timofte authored 4 days ago
2320
sub normalize_registry_policy {
2321
    my ($registry) = @_;
2322
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2323
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2324
    $registry->{policy}{runtime_database} = $opt{db};
2325
}
2326

            
2327
sub default_hosts_yaml {
2328
    return <<'YAML';
2329
version: 1
2330
updated_at: ""
2331
policy:
Bogdan Timofte authored 4 days ago
2332
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2333
hosts:
2334
YAML
2335
}
2336

            
2337
sub default_work_orders_yaml {
2338
    return <<'YAML';
2339
version: 1
2340
work_orders:
2341
YAML
2342
}
2343

            
2344
sub ensure_parent_dir {
2345
    my ($path) = @_;
2346
    my $dir = dirname($path);
2347
    make_path($dir) unless -d $dir;
2348
}
2349

            
Xdev Host Manager authored a week ago
2350
sub url_decode {
2351
    my ($value) = @_;
2352
    $value = '' unless defined $value;
2353
    $value =~ tr/+/ /;
2354
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2355
    return $value;
2356
}
2357

            
2358
sub random_hex {
2359
    my ($bytes) = @_;
2360
    if (open my $fh, '<:raw', '/dev/urandom') {
2361
        read($fh, my $raw, $bytes);
2362
        close $fh;
2363
        return unpack('H*', $raw);
2364
    }
2365
    return sha256_hex(rand() . time() . $$);
2366
}
2367

            
2368
sub iso_now {
2369
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2370
}
2371

            
Bogdan Timofte authored 6 days ago
2372
sub build_info {
2373
    my %info = (
2374
        revision => '',
2375
        branch => '',
2376
        built_at => '',
2377
        deployed_at => '',
2378
        dirty => '',
2379
    );
2380

            
2381
    if ($ENV{HOST_MANAGER_BUILD}) {
2382
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2383
        return \%info;
2384
    }
2385

            
2386
    my $build_file = "$project_dir/BUILD";
2387
    if (-f $build_file) {
2388
        for my $line (split /\n/, read_file($build_file)) {
2389
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2390
            $info{$1} = clean_scalar($2);
2391
        }
2392
        return \%info if $info{revision} || $info{built_at};
2393
    }
2394

            
2395
    my $revision = git_value('rev-parse --short=12 HEAD');
2396
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2397
    $info{revision} = $revision if $revision;
2398
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2399
    return \%info;
2400
}
2401

            
2402
sub git_value {
2403
    my ($args) = @_;
2404
    return '' unless -d "$project_dir/.git";
2405
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2406
    my $value = <$fh> || '';
2407
    close $fh;
2408
    chomp $value;
2409
    return clean_scalar($value);
2410
}
2411

            
2412
sub build_label {
2413
    my $info = build_info();
2414
    my $revision = $info->{revision} || 'unknown';
2415
    my $branch = $info->{branch} || '';
2416
    $branch = '' if $branch eq 'HEAD';
2417
    my $label = $branch ? "$branch $revision" : $revision;
2418
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2419
    return $label;
2420
}
2421

            
2422
sub build_title {
2423
    my $info = build_info();
2424
    my $label = build_label();
2425
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2426
    return $stamp ? "$label deployed $stamp" : $label;
2427
}
2428

            
Bogdan Timofte authored 4 days ago
2429
sub build_revision {
2430
    my $info = build_info();
2431
    return $info->{revision} || 'unknown';
2432
}
2433

            
2434
sub build_details {
2435
    my $info = build_info();
2436
    my %details = (
2437
        app => 'Madagascar Local Authority',
2438
        revision => $info->{revision} || 'unknown',
2439
        branch => $info->{branch} || '',
2440
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2441
        built_at => $info->{built_at} || '',
2442
        deployed_at => $info->{deployed_at} || '',
2443
        label => build_label(),
2444
        title => build_title(),
2445
    );
2446
    return json_encode(\%details);
2447
}
2448

            
Bogdan Timofte authored 6 days ago
2449
sub html_escape {
2450
    my ($value) = @_;
2451
    $value = '' unless defined $value;
2452
    $value =~ s/&/&amp;/g;
2453
    $value =~ s/</&lt;/g;
2454
    $value =~ s/>/&gt;/g;
2455
    $value =~ s/"/&quot;/g;
2456
    $value =~ s/'/&#039;/g;
2457
    return $value;
2458
}
2459

            
Xdev Host Manager authored a week ago
2460
sub app_html {
Bogdan Timofte authored 4 days ago
2461
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2462
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2463
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2464
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2465
<!doctype html>
2466
<html lang="ro">
2467
<head>
2468
  <meta charset="utf-8">
2469
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2470
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2471
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2472
  <style>
2473
    :root {
2474
      color-scheme: light;
2475
      --ink: #152033;
2476
      --muted: #647084;
2477
      --line: #d8dee8;
2478
      --soft: #f4f6f9;
2479
      --panel: #ffffff;
2480
      --accent: #1267d8;
2481
      --bad: #b42318;
2482
      --warn: #946200;
2483
      --ok: #137333;
2484
    }
2485
    * { box-sizing: border-box; }
2486
    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
2487

            
2488
    /* ── Login screen ── */
2489
    #login-screen {
2490
      display: flex;
Xdev Host Manager authored a week ago
2491
      align-items: flex-start;
Xdev Host Manager authored a week ago
2492
      justify-content: center;
2493
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2494
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2495
      background: #13182a;
Xdev Host Manager authored a week ago
2496
      overflow: auto;
Xdev Host Manager authored a week ago
2497
    }
2498
    .login-card {
Xdev Host Manager authored a week ago
2499
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2500
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2501
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2502
      background: #fff;
2503
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2504
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2505
         below the first box, sits inside the card instead of spilling past it. */
2506
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2507
      width: 100%;
Xdev Host Manager authored a week ago
2508
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2509
      min-height: 360px;
Xdev Host Manager authored a week ago
2510
      display: grid;
Xdev Host Manager authored a week ago
2511
      align-content: start;
2512
      justify-items: center;
2513
      gap: 28px;
Xdev Host Manager authored a week ago
2514
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2515
    }
Xdev Host Manager authored a week ago
2516
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2517
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2518
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2519
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2520
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2521
    }
Xdev Host Manager authored a week ago
2522
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2523
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2524
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2525
    .login-card form {
2526
      display: grid;
2527
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2528
      justify-self: center;
Bogdan Timofte authored a week ago
2529
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2530
    }
Xdev Host Manager authored a week ago
2531
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2532
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2533
       giving the password manager a username anchor and an aggregated OTP target
2534
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2535
    .pm-helper-fields {
2536
      position: absolute;
2537
      left: -10000px;
2538
      top: auto;
2539
      width: 1px;
2540
      height: 1px;
2541
      overflow: hidden;
2542
      opacity: 0.01;
2543
    }
2544
    .pm-helper-fields input {
2545
      width: 1px;
2546
      height: 1px;
2547
      padding: 0;
2548
      border: 0;
2549
    }
Bogdan Timofte authored 4 days ago
2550
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2551
       hint was what made Safari mark the whole group and re-present its OTP
2552
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2553
    .otp-row {
2554
      display: flex;
2555
      gap: var(--otp-gap);
2556
      justify-content: center;
2557
    }
Bogdan Timofte authored 4 days ago
2558
    .otp-row input {
Xdev Host Manager authored a week ago
2559
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
2560
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2561
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2562
      transition: border-color .15s, background .15s;
2563
    }
Bogdan Timofte authored 4 days ago
2564
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2565
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2566
    #login-error {
2567
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2568
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2569
    }
2570
    @media (max-width: 760px) {
2571
      .login-card {
Xdev Host Manager authored a week ago
2572
        max-width: 520px;
Xdev Host Manager authored a week ago
2573
        min-height: 0;
Bogdan Timofte authored 4 days ago
2574
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2575
        gap: 26px;
2576
      }
2577
      .login-card .brand h1 { font-size: 24px; }
2578
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2579
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2580
    }
Xdev Host Manager authored a week ago
2581
    @media (max-width: 430px) {
2582
      #login-screen { padding: 24px 16px 120px; }
2583
      .login-card {
2584
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2585
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2586
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2587
      }
Bogdan Timofte authored 4 days ago
2588
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2589
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2590
    }
2591
    @media (max-height: 720px) {
2592
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2593
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2594
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2595
    }
Xdev Host Manager authored a week ago
2596

            
2597
    /* ── App shell (hidden until authenticated) ── */
2598
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2599
    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
2600
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2601
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2602
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2603
    nav a:hover { color: var(--ink); background: var(--soft); }
2604
    nav a.active { color: var(--accent); background: #e8f0fe; }
2605
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2606
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2607
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2608
    .page { display: grid; gap: 16px; }
2609
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2610
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2611
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2612
    .panel { overflow: hidden; }
2613
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2614
    .panel-head h2 { margin: 0; font-size: 14px; }
2615
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2616
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2617
    button, input, select, textarea { font: inherit; }
2618
    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; }
2619
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2620
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2621
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2622
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2623
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2624
    textarea { min-height: 74px; resize: vertical; }
2625
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2626
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2627
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2628
    tr:hover td { background: #f8fafc; }
2629
    .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; }
2630
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2631
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2632
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
2633
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
2634
    .pill.canonical { font-weight: 700; }
2635
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
2636
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2637
    .span2 { grid-column: 1 / -1; }
2638
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2639
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2640
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2641
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2642
    .ca-fingerprint { overflow-wrap: anywhere; }
2643
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2644
    .build-control {
Bogdan Timofte authored 6 days ago
2645
      position: fixed;
2646
      right: 10px;
2647
      bottom: 8px;
2648
      z-index: 5;
Bogdan Timofte authored 4 days ago
2649
      display: inline-flex;
2650
      align-items: center;
2651
      gap: 4px;
2652
    }
2653
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2654
      color: rgba(255,255,255,.46);
2655
      background: rgba(19,24,42,.28);
2656
      border: 1px solid rgba(255,255,255,.08);
2657
      border-radius: 4px;
2658
      font-size: 10px;
2659
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2660
    }
2661
    .build-badge {
2662
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
2663
      cursor: text;
2664
      user-select: text;
Bogdan Timofte authored 6 days ago
2665
    }
Bogdan Timofte authored 4 days ago
2666
    .build-copy {
2667
      min-height: 0;
2668
      padding: 2px 5px;
2669
      cursor: pointer;
2670
    }
2671
    .build-copy:hover {
2672
      color: rgba(255,255,255,.72);
2673
      border-color: rgba(255,255,255,.24);
2674
    }
2675
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2676
      color: rgba(100,112,132,.58);
2677
      background: rgba(255,255,255,.72);
2678
      border-color: rgba(216,222,232,.72);
2679
    }
Bogdan Timofte authored 4 days ago
2680
    body.is-app .build-copy:hover {
2681
      color: rgba(21,32,51,.78);
2682
      border-color: rgba(100,112,132,.42);
2683
    }
Xdev Host Manager authored a week ago
2684
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2685
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2686
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2687
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2688
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2689
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2690
    .work-order-actions { gap: 4px; }
2691
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2692
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2693
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2694
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
2695
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
2696
    .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
2697
    .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
2698
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
2699
    .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
2700
    .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; }
2701
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
2702
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
2703
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
2704
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
2705
    .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; }
2706
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
2707
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
2708
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2709
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
2710
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2711
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2712
    .host-tools input { max-width: 240px; }
2713
    .modal-backdrop {
2714
      position: fixed;
2715
      inset: 0;
2716
      z-index: 10;
2717
      display: grid;
2718
      align-items: start;
2719
      justify-items: center;
2720
      padding: 72px 16px 24px;
2721
      background: rgba(21,32,51,.48);
2722
      overflow: auto;
2723
    }
2724
    .modal-backdrop[hidden] { display: none; }
2725
    .modal {
2726
      width: min(840px, 100%);
2727
      max-height: calc(100dvh - 96px);
2728
      overflow: auto;
2729
      background: var(--panel);
2730
      border: 1px solid var(--line);
2731
      border-radius: 8px;
2732
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2733
    }
2734
    .modal-head {
2735
      position: sticky;
2736
      top: 0;
2737
      z-index: 1;
2738
      display: flex;
2739
      align-items: center;
2740
      justify-content: space-between;
2741
      gap: 12px;
2742
      padding: 12px 14px;
2743
      border-bottom: 1px solid var(--line);
2744
      background: #fafbfc;
2745
    }
2746
    .modal-head h2 { margin: 0; font-size: 14px; }
2747
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2748
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2749
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2750
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2751
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2752
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2753
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2754
      #message { max-width: 100%; }
2755
      .panel-head { align-items: stretch; flex-direction: column; }
2756
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2757
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2758
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2759
      .modal-backdrop { padding-top: 16px; }
2760
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2761
      .grid { grid-template-columns: 1fr; }
2762
      table { min-width: 760px; }
2763
      .table-wrap { overflow-x: auto; }
2764
    }
2765
  </style>
2766
</head>
Bogdan Timofte authored 6 days ago
2767
<body class="is-login">
Xdev Host Manager authored a week ago
2768

            
Xdev Host Manager authored a week ago
2769
  <!-- ── Login screen ── -->
2770
  <div id="login-screen">
2771
    <div class="login-card">
2772
      <div class="brand">
2773
        <div class="icon">
Xdev Host Manager authored a week ago
2774
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2775
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2776
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2777
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2778
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2779
            <path d="M26 20h8M26 32h8M26 44h8"/>
2780
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2781
          </svg>
2782
        </div>
Xdev Host Manager authored a week ago
2783
        <h1>Madagascar Local Authority</h1>
2784
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2785
      </div>
Bogdan Timofte authored 4 days ago
2786
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2787
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2788
        <div class="pm-helper-fields" aria-hidden="true">
2789
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2790
          <input type="hidden" id="otp-hidden" name="otp">
2791
        </div>
Xdev Host Manager authored a week ago
2792
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2793
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2794
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2795
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2796
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2797
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2798
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2799
        </div>
2800
      </form>
2801
    </div>
2802
  </div>
2803

            
2804
  <!-- ── App (shown after login) ── -->
2805
  <div id="app">
2806
    <header>
Xdev Host Manager authored a week ago
2807
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2808
      <nav aria-label="Sections">
2809
        <a href="/overview" data-page-link="overview">Overview</a>
2810
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
2811
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
2812
        <a href="/dns" data-page-link="dns">DNS</a>
2813
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2814
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2815
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2816
      </nav>
Xdev Host Manager authored a week ago
2817
      <div class="header-right">
2818
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2819
        <span id="message" class="muted"></span>
2820
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2821
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2822
      </div>
Xdev Host Manager authored a week ago
2823
    </header>
2824
    <main>
Bogdan Timofte authored 5 days ago
2825
      <section class="page" id="page-overview" data-page="overview">
2826
        <section class="panel">
2827
          <div class="panel-head">
2828
            <h2>Overview</h2>
2829
            <div class="stats" id="stats"></div>
2830
          </div>
2831
          <div class="problems" id="problems"></div>
2832
        </section>
Xdev Host Manager authored a week ago
2833
      </section>
2834

            
Bogdan Timofte authored 5 days ago
2835
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2836
        <section class="panel">
2837
          <div class="panel-head">
2838
            <h2>Hosts</h2>
2839
            <div class="host-tools">
2840
              <input id="filter" placeholder="filter">
2841
              <button type="button" id="new-host">New host</button>
2842
            </div>
2843
          </div>
2844
          <div class="table-wrap">
2845
            <table>
2846
              <thead>
2847
                <tr>
2848
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
2849
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
2850
                  <th>Names</th>
2851
                  <th style="width: 150px">Roles</th>
2852
                  <th style="width: 110px">Monitoring</th>
2853
                  <th style="width: 90px">Status</th>
2854
                </tr>
2855
              </thead>
2856
              <tbody id="hosts"></tbody>
2857
            </table>
2858
          </div>
2859
        </section>
Xdev Host Manager authored a week ago
2860
      </section>
Xdev Host Manager authored a week ago
2861

            
Bogdan Timofte authored 4 days ago
2862
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
2863
        <section class="panel">
2864
          <div class="panel-head">
2865
            <h2>Vhosts</h2>
2866
            <div class="host-tools">
2867
              <input id="vhost-filter" placeholder="filter">
2868
              <div class="stats" id="vhost-stats"></div>
2869
            </div>
2870
          </div>
2871
          <div class="table-wrap">
2872
            <table>
2873
              <thead>
2874
                <tr>
2875
                  <th>Vhost</th>
2876
                  <th style="width: 190px">Host</th>
2877
                  <th style="width: 140px">IP</th>
2878
                  <th style="width: 180px">Derived aliases</th>
2879
                  <th style="width: 120px">Monitoring</th>
2880
                  <th style="width: 90px">Status</th>
2881
                </tr>
2882
              </thead>
2883
              <tbody id="vhosts"></tbody>
2884
            </table>
2885
          </div>
2886
        </section>
2887
      </section>
2888

            
Bogdan Timofte authored 5 days ago
2889
      <section class="page" id="page-dns" data-page="dns" hidden>
2890
        <section class="toolbar">
2891
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2892
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2893
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2894
          <button id="write-tsv">Write local-hosts.tsv</button>
2895
        </section>
Xdev Host Manager authored a week ago
2896
      </section>
2897

            
Bogdan Timofte authored 5 days ago
2898
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2899
        <section class="panel">
2900
          <div class="panel-head">
2901
            <h2>Work Orders</h2>
2902
            <div class="stats" id="wo-stats"></div>
2903
          </div>
2904
          <div class="problems" id="work-orders"></div>
2905
        </section>
Xdev Host Manager authored a week ago
2906
      </section>
2907

            
Bogdan Timofte authored 5 days ago
2908
      <section class="page" id="page-ca" data-page="ca" hidden>
2909
        <section class="panel">
2910
          <div class="panel-head">
2911
            <h2>Local Certificate Authority</h2>
2912
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2913
          </div>
2914
          <div class="problems" id="ca-status"></div>
2915
        </section>
2916
        <section class="panel">
2917
          <div class="panel-head">
2918
            <h2>Issued Certificates</h2>
2919
            <div class="stats" id="ca-certs-summary"></div>
2920
          </div>
2921
          <div class="table-wrap">
2922
            <table>
2923
              <thead>
2924
                <tr>
2925
                  <th style="width: 150px">Name</th>
2926
                  <th>DNS names</th>
2927
                  <th style="width: 210px">Validity</th>
2928
                  <th style="width: 180px">Serial</th>
2929
                  <th>Fingerprint</th>
2930
                  <th style="width: 110px">Download</th>
2931
                </tr>
2932
              </thead>
2933
              <tbody id="ca-certs"></tbody>
2934
            </table>
2935
          </div>
2936
        </section>
Xdev Host Manager authored a week ago
2937
      </section>
Bogdan Timofte authored 4 days ago
2938

            
2939
      <section class="page" id="page-debug" data-page="debug" hidden>
2940
        <section class="panel">
2941
          <div class="panel-head">
2942
            <h2>Database</h2>
2943
            <div class="stats" id="debug-db-stats"></div>
2944
          </div>
2945
          <div class="toolbar">
2946
            <div class="debug-controls">
2947
              <button type="button" id="debug-db-refresh">Refresh</button>
2948
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
2949
            </div>
2950
          </div>
Bogdan Timofte authored 4 days ago
2951
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
2952
        </section>
2953
        <section class="debug-section">
2954
          <section class="panel">
2955
            <div class="panel-head">
2956
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
2957
              <div class="debug-table-head-actions">
2958
                <div class="stats" id="debug-table-stats"></div>
2959
                <div class="debug-table-exports">
2960
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
2961
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
2962
                </div>
2963
              </div>
Bogdan Timofte authored 4 days ago
2964
            </div>
2965
            <div class="table-wrap" id="debug-table-rows"></div>
2966
          </section>
2967
          <section class="panel">
2968
            <div class="panel-head">
2969
              <h2>Columns</h2>
2970
            </div>
2971
            <div class="table-wrap" id="debug-table-columns"></div>
2972
          </section>
2973
          <section class="panel">
2974
            <div class="panel-head">
2975
              <h2>Indexes</h2>
2976
            </div>
2977
            <div class="table-wrap" id="debug-table-indexes"></div>
2978
          </section>
2979
          <section class="panel">
2980
            <div class="panel-head">
2981
              <h2>Foreign Keys</h2>
2982
            </div>
2983
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
2984
          </section>
2985
        </section>
2986
      </section>
Bogdan Timofte authored 5 days ago
2987
    </main>
Xdev Host Manager authored a week ago
2988

            
Bogdan Timofte authored 5 days ago
2989
    <div id="host-modal" class="modal-backdrop" hidden>
2990
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
2991
        <div class="modal-head">
2992
          <h2 id="host-modal-title">Edit host</h2>
2993
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
2994
        </div>
2995
        <form id="host-form" class="grid">
2996
          <label>ID<input name="id" required></label>
Bogdan Timofte authored 4 days ago
2997
          <label>FQDN<input name="fqdn" required></label>
Xdev Host Manager authored a week ago
2998
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
Bogdan Timofte authored 4 days ago
2999
          <label>IP<input name="ip" required></label>
3000
          <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3001
          <label class="span2">Vhosts<textarea name="vhosts"></textarea></label>
Xdev Host Manager authored a week ago
3002
          <label>Roles<input name="roles"></label>
3003
          <label>Sources<input name="sources"></label>
3004
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3005
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
3006
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
3007
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
3008
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
3009
            <button class="danger" type="button" id="delete-host">Delete host</button>
3010
          </div>
3011
        </form>
3012
      </section>
Bogdan Timofte authored 5 days ago
3013
    </div>
Xdev Host Manager authored a week ago
3014
  </div>
3015

            
Bogdan Timofte authored 4 days ago
3016
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3017
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3018
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3019
  </div>
Bogdan Timofte authored 6 days ago
3020

            
Xdev Host Manager authored a week ago
3021
  <script>
Bogdan Timofte authored 4 days ago
3022
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3023
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
3024

            
3025
    const $ = (id) => document.getElementById(id);
3026
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
3027
    const PAGE_PATHS = {
3028
      '/': 'overview',
3029
      '/overview': 'overview',
3030
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3031
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3032
      '/dns': 'dns',
3033
      '/work-orders': 'work-orders',
3034
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3035
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3036
    };
Xdev Host Manager authored a week ago
3037

            
Bogdan Timofte authored 4 days ago
3038
    function isAuthLost(error) {
3039
      return !!(error && error.authLost);
3040
    }
3041

            
3042
    function authLostError(message) {
3043
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3044
      error.authLost = true;
3045
      return error;
3046
    }
3047

            
3048
    function handleAuthLost(message) {
3049
      state.authenticated = false;
3050
      msg('');
3051
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3052
    }
3053

            
Bogdan Timofte authored 4 days ago
3054
    async function ensureAuthenticated(message) {
3055
      if (!state.authenticated) {
3056
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3057
        return false;
3058
      }
3059
      const session = await api('/api/session');
3060
      state.authenticated = session.authenticated;
3061
      if (!state.authenticated) {
3062
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3063
        return false;
3064
      }
3065
      return true;
3066
    }
3067

            
Xdev Host Manager authored a week ago
3068
    async function api(path, options = {}) {
3069
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3070
      let body = {};
3071
      try {
3072
        body = await res.json();
3073
      } catch (_) {
3074
        body = {};
3075
      }
3076
      const errorCode = body.error || '';
3077
      if (!res.ok) {
3078
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3079
          const error = authLostError();
3080
          handleAuthLost(error.message);
3081
          throw error;
3082
        }
3083
        throw new Error(errorCode || res.statusText);
3084
      }
Xdev Host Manager authored a week ago
3085
      return body;
3086
    }
3087

            
Bogdan Timofte authored 5 days ago
3088
    function currentPage() {
3089
      return PAGE_PATHS[window.location.pathname] || 'overview';
3090
    }
3091

            
3092
    function showPage(page, push = false) {
3093
      const target = page || 'overview';
3094
      document.querySelectorAll('[data-page]').forEach(section => {
3095
        section.hidden = section.dataset.page !== target;
3096
      });
3097
      document.querySelectorAll('[data-page-link]').forEach(link => {
3098
        link.classList.toggle('active', link.dataset.pageLink === target);
3099
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3100
      });
3101
      if (push) {
3102
        const href = target === 'overview' ? '/overview' : '/' + target;
3103
        history.pushState({ page: target }, '', href);
3104
      }
Bogdan Timofte authored 4 days ago
3105
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3106
        renderDebugDatabase().catch(e => {
3107
          if (!isAuthLost(e)) msg(e.message);
3108
        });
Bogdan Timofte authored 4 days ago
3109
      }
Bogdan Timofte authored 5 days ago
3110
    }
3111

            
Xdev Host Manager authored a week ago
3112
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3113
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3114
      document.body.classList.remove('is-app');
3115
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3116
      $('app').style.display = 'none';
3117
      $('login-screen').style.display = 'flex';
3118
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3119
      clearOtp();
Xdev Host Manager authored a week ago
3120
    }
3121

            
3122
    function showApp() {
Bogdan Timofte authored 6 days ago
3123
      document.body.classList.remove('is-login');
3124
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3125
      $('login-screen').style.display = 'none';
3126
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3127
      showPage(currentPage());
Xdev Host Manager authored a week ago
3128
    }
3129

            
Xdev Host Manager authored a week ago
3130
    async function refresh() {
3131
      const session = await api('/api/session');
3132
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3133
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3134
      showApp();
Xdev Host Manager authored a week ago
3135
      const data = await api('/api/hosts');
3136
      state.hosts = data.hosts || [];
3137
      state.problems = data.problems || [];
3138
      render(data);
Xdev Host Manager authored a week ago
3139
      await renderCa();
Xdev Host Manager authored a week ago
3140
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3141
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3142
    }
3143

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

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

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

            
3157
      renderHosts();
Bogdan Timofte authored 4 days ago
3158
      renderVhosts();
Xdev Host Manager authored a week ago
3159
    }
3160

            
Xdev Host Manager authored a week ago
3161
    async function renderCa() {
3162
      try {
3163
        const status = await api('/api/ca/status');
3164
        if (!status.initialized) {
3165
          $('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
3166
          $('ca-certs-summary').innerHTML = '';
3167
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3168
          return;
3169
        }
3170
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
3171
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3172
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3173
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3174
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3175
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3176
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3177
            <div>
3178
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3179
              <span>${certs.length} issued certificate(s)</span>
3180
            </div>
Xdev Host Manager authored a week ago
3181
          </div>`;
Bogdan Timofte authored 5 days ago
3182
        $('ca-certs-summary').innerHTML = [
3183
          ['issued', certs.length],
3184
          ['expiring', certs.filter(cert => {
3185
            const days = daysUntil(cert.not_after);
3186
            return days !== null && days >= 0 && days <= 30;
3187
          }).length],
3188
          ['expired', certs.filter(cert => {
3189
            const days = daysUntil(cert.not_after);
3190
            return days !== null && days < 0;
3191
          }).length],
3192
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3193
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3194
          const days = daysUntil(cert.not_after);
3195
          const dnsNames = cert.dns_names || [];
3196
          const dnsHtml = dnsNames.length
3197
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3198
            : '<span class="muted">No DNS SANs reported.</span>';
3199
          return `<tr>
3200
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3201
            <td>${dnsHtml}</td>
3202
            <td>
3203
              <div class="ca-detail">
3204
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3205
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3206
              </div>
3207
            </td>
3208
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3209
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
3210
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
3211
          </tr>`;
3212
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3213
      } catch (e) {
Bogdan Timofte authored 4 days ago
3214
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3215
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3216
        $('ca-certs-summary').innerHTML = '';
3217
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3218
      }
3219
    }
3220

            
Bogdan Timofte authored 5 days ago
3221
    function daysUntil(dateText) {
3222
      const time = Date.parse(dateText || '');
3223
      if (!Number.isFinite(time)) return null;
3224
      return Math.ceil((time - Date.now()) / 86400000);
3225
    }
3226

            
3227
    function certStatusClass(days) {
3228
      if (days === null) return '';
3229
      if (days < 0) return 'bad';
3230
      if (days <= 30) return 'warn';
3231
      return 'ok';
3232
    }
3233

            
3234
    function certStatusLabel(days) {
3235
      if (days === null) return 'validity unknown';
3236
      if (days < 0) return 'expired';
3237
      if (days === 0) return 'expires today';
3238
      return `${days}d remaining`;
3239
    }
3240

            
Xdev Host Manager authored a week ago
3241
    async function renderWorkOrders() {
3242
      try {
3243
        const data = await api('/api/work-orders');
3244
        state.workOrders = data.work_orders || [];
3245
        $('wo-stats').innerHTML = [
3246
          ['pending', data.counts.pending],
3247
          ['total', data.counts.work_orders],
3248
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3249

            
3250
        if (!state.workOrders.length) {
3251
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3252
          return;
3253
        }
3254

            
3255
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3256
          const checklist = wo.checklist || [];
3257
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3258
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3259
          const checklistHtml = checklist.map(item => {
3260
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3261
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3262
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3263
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3264
            </label>`;
3265
          }).join('');
Xdev Host Manager authored a week ago
3266
          const actions = (wo.actions || []).map(a => {
3267
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3268
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3269
          }).join('');
3270
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3271
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3272
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3273
            : '';
Bogdan Timofte authored 6 days ago
3274
          return `<div class="problem work-order-card">
3275
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3276
              <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
3277
              ${button}
3278
            </div>
Bogdan Timofte authored 6 days ago
3279
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3280
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3281
            <div class="work-order-checklist">${checklistHtml}</div>
3282
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3283
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3284
          </div>`;
3285
        }).join('');
Xdev Host Manager authored a week ago
3286
        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
3287
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3288
      } catch (e) {
Bogdan Timofte authored 4 days ago
3289
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3290
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3291
      }
3292
    }
3293

            
Bogdan Timofte authored 4 days ago
3294
    async function renderDebugDatabase() {
3295
      if (!state.authenticated) return;
3296
      const data = await api('/api/debug/database/tables');
3297
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3298
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3299
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3300
      $('debug-db-stats').innerHTML = [
3301
        ['tables', data.counts ? data.counts.tables : tables.length],
3302
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3303
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3304
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3305
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3306
      if (selected) {
3307
        await renderDebugTable(selected);
3308
      } else {
3309
        clearDebugTable();
3310
      }
3311
    }
3312

            
Bogdan Timofte authored 4 days ago
3313
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3314
      $('debug-db-tables').innerHTML = tables.length
3315
        ? tables.map(table => {
3316
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3317
            const ref = debugTableReference(database, table.name);
3318
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3319
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3320
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3321
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3322
              </button>
Bogdan Timofte authored 4 days ago
3323
              <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
3324
            </div>`;
Bogdan Timofte authored 4 days ago
3325
          }).join('')
3326
        : '<div class="ca-empty muted">No database tables found.</div>';
3327
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3328
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3329
          if (!isAuthLost(e)) msg(e.message);
3330
        }));
3331
      });
Bogdan Timofte authored 4 days ago
3332
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3333
        button.addEventListener('click', async () => {
3334
          try {
3335
            await copyText(button.dataset.debugTableRef || '');
3336
            msg('table reference copied');
3337
          } catch (e) {
3338
            msg('copy failed');
3339
          }
3340
        });
3341
      });
3342
    }
3343

            
3344
    function debugTableReference(database, tableName) {
3345
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3346
    }
3347

            
3348
    async function selectDebugTable(tableName) {
3349
      state.debugTable = tableName || '';
3350
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3351
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3352
        const card = button.closest('.debug-table-card');
3353
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3354
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3355
      });
3356
      if (state.debugTable) await renderDebugTable(state.debugTable);
3357
    }
3358

            
3359
    function clearDebugTable() {
3360
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3361
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3362
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3363
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3364
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3365
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3366
    }
3367

            
3368
    async function renderDebugTable(tableName) {
3369
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3370
      if (data.error) throw new Error(data.error);
3371
      $('debug-table-stats').innerHTML = [
3372
        ['table', data.table || tableName],
3373
        ['rows', data.row_count || 0],
3374
        ['shown', (data.rows || []).length],
3375
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3376
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3377
      renderDebugRows(data);
3378
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3379
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3380
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3381
    }
3382

            
Bogdan Timofte authored 4 days ago
3383
    function updateDebugExportLinks(tableName) {
3384
      const encoded = encodeURIComponent(tableName || '');
3385
      [
3386
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3387
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3388
      ].forEach(([id, href]) => {
3389
        const link = $(id);
3390
        const enabled = !!tableName;
3391
        link.href = enabled ? href : '#';
3392
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3393
      });
3394
    }
3395

            
Bogdan Timofte authored 4 days ago
3396
    function renderDebugRows(data) {
3397
      const rows = data.rows || [];
3398
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3399
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3400
    }
3401

            
3402
    function renderDebugObjectTable(rows, preferredKeys) {
3403
      const keys = preferredKeys && preferredKeys.length
3404
        ? preferredKeys
3405
        : Array.from(rows.reduce((set, row) => {
3406
            Object.keys(row || {}).forEach(key => set.add(key));
3407
            return set;
3408
          }, new Set()));
3409
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3410
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3411
      const body = rows.length
3412
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3413
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3414
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3415
    }
3416

            
3417
    function debugCell(value) {
3418
      if (value === null || value === undefined) return 'NULL';
3419
      if (Array.isArray(value)) return value.join(', ');
3420
      if (typeof value === 'object') return JSON.stringify(value);
3421
      return String(value);
3422
    }
3423

            
Xdev Host Manager authored a week ago
3424
    async function updateWorkOrderChecklist(id, itemId, checked) {
3425
      try {
3426
        await api('/api/work-orders/checklist', {
3427
          method: 'POST',
3428
          headers: { 'Content-Type': 'application/json' },
3429
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3430
        });
3431
        msg('work order updated');
3432
        await refresh();
Bogdan Timofte authored 4 days ago
3433
      } catch (e) {
3434
        if (isAuthLost(e)) return;
3435
        msg(e.message);
3436
        await refresh().catch(refreshError => {
3437
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3438
        });
3439
      }
Xdev Host Manager authored a week ago
3440
    }
3441

            
Xdev Host Manager authored a week ago
3442
    async function confirmWorkOrder(id) {
3443
      const typed = prompt(`Type ${id} to confirm this work order`);
3444
      if (typed !== id) return;
3445
      try {
3446
        await api('/api/work-orders/confirm', {
3447
          method: 'POST',
3448
          headers: { 'Content-Type': 'application/json' },
3449
          body: JSON.stringify({ id, confirm: typed })
3450
        });
3451
        msg('work order confirmed; local-hosts.tsv written');
3452
        await refresh();
Bogdan Timofte authored 4 days ago
3453
      } catch (e) {
3454
        if (isAuthLost(e)) return;
3455
        msg(e.message);
3456
      }
Xdev Host Manager authored a week ago
3457
    }
3458

            
Xdev Host Manager authored a week ago
3459
    function renderHosts() {
3460
      const filter = $('filter').value.toLowerCase();
3461
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3462
        .slice()
3463
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3464
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3465
        .map(h => {
3466
          const problems = state.problems.filter(p => p.host_id === h.id);
3467
          const cls = problems.length ? 'warn' : 'ok';
3468
          return `<tr data-id="${escapeHtml(h.id)}">
3469
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
Bogdan Timofte authored 4 days ago
3470
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
3471
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3472
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3473
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3474
            <td>${escapeHtml(h.status || '')}</td>
3475
          </tr>`;
3476
        }).join('');
Bogdan Timofte authored 4 days ago
3477
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3478
        editHost(button.dataset.edit).catch(e => {
3479
          if (!isAuthLost(e)) msg(e.message);
3480
        });
3481
      }));
Xdev Host Manager authored a week ago
3482
    }
3483

            
Bogdan Timofte authored 4 days ago
3484
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3485
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3486
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3487
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
3488
      const vhosts = (host.vhosts || []).map(name => `<span class="pill vhost">${escapeHtml(name)}</span>`).join('');
3489
      const derivedVhostAliases = (host.derived_vhost_aliases || []).map(name => `<span class="pill derived vhost" title="derived vhost alias">${escapeHtml(name)}</span>`).join('');
3490
      return canonical + aliases + derivedAliases + vhosts + derivedVhostAliases;
3491
    }
3492

            
3493
    function vhostRows() {
3494
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3495
        vhost,
3496
        host_id: host.id || '',
3497
        host_fqdn: host.fqdn || '',
3498
        ip: host.ip || '',
3499
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3500
        monitoring: host.monitoring || '',
3501
        status: host.status || '',
3502
      })));
3503
    }
3504

            
3505
    function renderVhosts() {
3506
      const input = $('vhost-filter');
3507
      const filter = input ? input.value.toLowerCase() : '';
3508
      const rows = vhostRows()
3509
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3510
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3511
      $('vhost-stats').innerHTML = [
3512
        ['shown', rows.length],
3513
        ['total', vhostRows().length],
3514
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3515
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3516
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
3517
        <td><button type="button" data-edit-vhost-host="${escapeHtml(row.host_id)}">${escapeHtml(row.host_id)}</button><div class="muted mono">${escapeHtml(row.host_fqdn)}</div></td>
3518
        <td>${escapeHtml(row.ip)}</td>
3519
        <td>${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</td>
3520
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3521
        <td>${escapeHtml(row.status)}</td>
3522
      </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
3523
      document.querySelectorAll('[data-edit-vhost-host]').forEach(button => button.addEventListener('click', () => {
3524
        editHost(button.dataset.editVhostHost).catch(e => {
3525
          if (!isAuthLost(e)) msg(e.message);
3526
        });
3527
      }));
3528
    }
3529

            
3530
    function shortAliasForFqdn(name) {
3531
      const suffix = '.madagascar.xdev.ro';
3532
      name = String(name || '').toLowerCase();
3533
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3534
    }
3535

            
Bogdan Timofte authored 4 days ago
3536
    async function editHost(id) {
3537
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
3538
      const host = state.hosts.find(h => h.id === id);
3539
      if (!host) return;
3540
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3541
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3542
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3543
      hostField('aliases').value = (host.aliases || []).join('\n');
3544
      hostField('vhosts').value = (host.vhosts || []).join('\n');
Bogdan Timofte authored 5 days ago
3545
      hostField('roles').value = (host.roles || []).join(' ');
3546
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3547
      openHostModal('Edit host');
3548
    }
3549

            
Bogdan Timofte authored 4 days ago
3550
    async function newHost() {
3551
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 5 days ago
3552
      const form = $('host-form');
3553
      form.reset();
Bogdan Timofte authored 5 days ago
3554
      clearHostFormMessage();
3555
      hostField('status').value = 'active';
3556
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3557
      openHostModal('New host');
3558
    }
3559

            
3560
    function openHostModal(title) {
3561
      $('host-modal-title').textContent = title || 'Edit host';
3562
      $('host-modal').hidden = false;
3563
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3564
      hostFormSnapshot = hostFormState();
3565
      hostField('id').focus();
3566
    }
3567

            
3568
    function requestCloseHostModal() {
3569
      if ($('save-host').disabled) return;
3570
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3571
      closeHostModal();
Bogdan Timofte authored 5 days ago
3572
    }
3573

            
3574
    function closeHostModal() {
3575
      $('host-modal').hidden = true;
3576
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3577
      setHostFormBusy(false);
3578
      clearHostFormMessage();
3579
      hostFormSnapshot = '';
3580
    }
3581

            
3582
    function hostField(name) {
3583
      return $('host-form').elements.namedItem(name);
3584
    }
3585

            
3586
    function hostFormState() {
3587
      return JSON.stringify(formObject($('host-form')));
3588
    }
3589

            
3590
    function hostFormDirty() {
3591
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3592
    }
3593

            
3594
    function setHostFormBusy(busy) {
3595
      $('save-host').disabled = busy;
3596
      $('delete-host').disabled = busy;
3597
      $('close-host-modal').disabled = busy;
3598
    }
3599

            
3600
    function setHostFormMessage(text, isError = false) {
3601
      const message = $('host-form-message');
3602
      message.textContent = text || '';
3603
      message.classList.toggle('error', !!isError);
3604
    }
3605

            
3606
    function clearHostFormMessage() {
3607
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3608
    }
3609

            
3610
    function formObject(form) {
3611
      return Object.fromEntries(new FormData(form).entries());
3612
    }
3613

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

            
Bogdan Timofte authored 6 days ago
3619
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3620

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

            
3626
    if (loginAccount) {
3627
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3628
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3629
      loginAccount.addEventListener('input', () => {
3630
        const value = (loginAccount.value || '').trim();
3631
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3632
      });
3633
    }
3634

            
Xdev Host Manager authored a week ago
3635
    function setOtpDigit(idx, value) {
3636
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3637
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3638
      otpDigits[idx].classList.toggle('filled', !!digit);
3639
    }
3640

            
Bogdan Timofte authored 4 days ago
3641
    // Move focus to the next empty box: forward from idx, then wrapping to the
3642
    // start. This lets out-of-order entry continue (e.g. after the last box,
3643
    // jump back to the first still-empty box). Stays put when all boxes are full.
3644
    function advanceFocus(idx) {
3645
      for (let i = idx + 1; i < otpDigits.length; i++) {
3646
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3647
      }
3648
      for (let i = 0; i <= idx; i++) {
3649
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3650
      }
3651
    }
3652

            
Bogdan Timofte authored 4 days ago
3653
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3654
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3655
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
3656
      const digits = (text || '').replace(/\D/g, '').split('');
3657
      if (!digits.length) return;
3658
      let last = startIdx;
3659
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3660
        last = startIdx + i;
3661
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3662
      }
Bogdan Timofte authored 4 days ago
3663
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3664
      advanceFocus(last);
Xdev Host Manager authored a week ago
3665
      maybeSubmitOtp();
3666
    }
3667

            
Bogdan Timofte authored 4 days ago
3668
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3669
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3670
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3671
    function maybeSubmitOtp() {
3672
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3673
    }
3674
    function clearOtp() {
Bogdan Timofte authored 4 days ago
3675
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3676
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3677
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3678
      // an unknown operator, so Safari's autofill anchor on the username stays.
3679
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3680
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3681
    }
3682

            
Bogdan Timofte authored 4 days ago
3683
    otpDigits.forEach((input, idx) => {
3684
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3685
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3686
        // A single box may receive several digits at once (autofill / typing fast).
3687
        if (input.value.replace(/\D/g, '').length > 1) {
3688
          fillOtp(input.value, idx);
3689
          return;
3690
        }
3691
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
3692
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3693
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
3694
        maybeSubmitOtp();
3695
      });
Bogdan Timofte authored 4 days ago
3696

            
3697
      input.addEventListener('paste', (e) => {
3698
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3699
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3700
        const text = (e.clipboardData || window.clipboardData).getData('text');
3701
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3702
      });
Bogdan Timofte authored 4 days ago
3703

            
3704
      input.addEventListener('keydown', (e) => {
3705
        if (e.key === 'Backspace') {
3706
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3707
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3708
          if (input.value) { setOtpDigit(idx, ''); }
3709
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3710
          syncOtpFields();
3711
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3712
          e.preventDefault();
3713
          otpDigits[idx - 1].focus();
3714
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3715
          e.preventDefault();
3716
          otpDigits[idx + 1].focus();
3717
        }
3718
      });
3719
    });
3720

            
Bogdan Timofte authored 4 days ago
3721
    // Focus the first OTP box only for a returning operator (username known).
3722
    // For an unknown operator, leave focus on the username field so Safari can
3723
    // present its OTP autofill anchored there without being dismissed by a focus
3724
    // change (pbx-admin pattern).
3725
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3726
    else if (loginAccount) loginAccount.focus();
3727
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3728

            
Bogdan Timofte authored 5 days ago
3729
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
3730
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
3731
        event.preventDefault();
Bogdan Timofte authored 4 days ago
3732
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
3733
        showPage(link.dataset.pageLink, true);
3734
      });
3735
    });
3736

            
Bogdan Timofte authored 4 days ago
3737
    window.addEventListener('popstate', () => {
3738
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3739
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3740
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3741
    });
Bogdan Timofte authored 5 days ago
3742

            
Bogdan Timofte authored 4 days ago
3743
    async function copyText(text) {
3744
      if (navigator.clipboard && window.isSecureContext) {
3745
        await navigator.clipboard.writeText(text);
3746
        return;
3747
      }
3748
      const input = document.createElement('textarea');
3749
      input.value = text;
3750
      input.setAttribute('readonly', '');
3751
      input.style.position = 'fixed';
3752
      input.style.left = '-10000px';
3753
      document.body.appendChild(input);
3754
      input.select();
3755
      document.execCommand('copy');
3756
      document.body.removeChild(input);
3757
    }
3758

            
3759
    $('copy-build').addEventListener('click', async () => {
3760
      try {
3761
        await copyText($('copy-build').dataset.buildDetails || '');
3762
        if (state.authenticated) msg('build details copied');
3763
      } catch (e) {
3764
        if (state.authenticated) msg('copy failed');
3765
      }
3766
    });
3767

            
Xdev Host Manager authored a week ago
3768
    $('login-form').addEventListener('submit', async (event) => {
3769
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3770
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3771
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3772
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3773
      try {
Xdev Host Manager authored a week ago
3774
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3775
        await refresh();
Xdev Host Manager authored a week ago
3776
      } catch (e) {
3777
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3778
      } finally {
Xdev Host Manager authored a week ago
3779
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3780
      }
Xdev Host Manager authored a week ago
3781
    });
3782

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

            
Bogdan Timofte authored 4 days ago
3788
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3789
      if (!isAuthLost(e)) msg(e.message);
3790
    }));
Xdev Host Manager authored a week ago
3791
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
3792
    $('vhost-filter').addEventListener('input', renderVhosts);
3793
    $('new-host').addEventListener('click', () => {
3794
      newHost().catch(e => {
3795
        if (!isAuthLost(e)) msg(e.message);
3796
      });
3797
    });
Bogdan Timofte authored 4 days ago
3798
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3799
      if (!isAuthLost(e)) msg(e.message);
3800
    }));
Bogdan Timofte authored 5 days ago
3801
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 4 days ago
3802
    $('host-modal').addEventListener('click', (event) => {
3803
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3804
    });
Bogdan Timofte authored 5 days ago
3805
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3806
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3807
    });
Xdev Host Manager authored a week ago
3808

            
Xdev Host Manager authored a week ago
3809
    $('host-form').addEventListener('submit', async (event) => {
3810
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3811
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
3812
      setHostFormBusy(true);
3813
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3814
      try {
3815
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3816
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3817
        closeHostModal();
Xdev Host Manager authored a week ago
3818
        msg('host saved');
3819
        await refresh();
Bogdan Timofte authored 5 days ago
3820
      } catch (e) {
Bogdan Timofte authored 4 days ago
3821
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3822
        setHostFormMessage(e.message, true);
3823
        msg(e.message);
3824
      } finally {
3825
        setHostFormBusy(false);
3826
      }
3827
    });
3828

            
3829
    $('host-form').addEventListener('invalid', (event) => {
3830
      setHostFormMessage('Complete the required host fields before saving.', true);
3831
    }, true);
3832

            
3833
    $('host-form').addEventListener('input', () => {
3834
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3835
    });
3836

            
3837
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3838
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3839
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3840
      setHostFormBusy(true);
3841
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3842
      try {
3843
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3844
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3845
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3846
        closeHostModal();
Xdev Host Manager authored a week ago
3847
        msg('host deleted');
3848
        await refresh();
Bogdan Timofte authored 5 days ago
3849
      } catch (e) {
Bogdan Timofte authored 4 days ago
3850
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3851
        setHostFormMessage(e.message, true);
3852
        msg(e.message);
3853
      } finally {
3854
        setHostFormBusy(false);
3855
      }
Xdev Host Manager authored a week ago
3856
    });
3857

            
3858
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3859
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3860
      try {
3861
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3862
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
3863
      } catch (e) {
3864
        if (!isAuthLost(e)) msg(e.message);
3865
      }
Xdev Host Manager authored a week ago
3866
    });
3867

            
Bogdan Timofte authored 4 days ago
3868
    refresh().catch(e => {
3869
      if (!isAuthLost(e)) showLogin(e.message);
3870
    });
Xdev Host Manager authored a week ago
3871
  </script>
3872
</body>
3873
</html>
3874
HTML
Bogdan Timofte authored 6 days ago
3875
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3876
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3877
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3878
    return $html;
Xdev Host Manager authored a week ago
3879
}