LocalAuthority / scripts / host_manager.pl
Newer Older
3885 lines | 149.482kb
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; }
Bogdan Timofte authored 4 days ago
2713
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
2714
    #page-vhosts .host-tools { flex-wrap: wrap; }
2715
    #page-vhosts .host-tools input { max-width: 280px; }
2716
    #page-vhosts .stats { justify-content: flex-end; }
2717
    .vhost-host { display: grid; gap: 2px; }
2718
    .vhost-host .mono { font-size: 11px; line-height: 1.2; color: var(--muted); }
2719
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
2720
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 5 days ago
2721
    .modal-backdrop {
2722
      position: fixed;
2723
      inset: 0;
2724
      z-index: 10;
2725
      display: grid;
2726
      align-items: start;
2727
      justify-items: center;
2728
      padding: 72px 16px 24px;
2729
      background: rgba(21,32,51,.48);
2730
      overflow: auto;
2731
    }
2732
    .modal-backdrop[hidden] { display: none; }
2733
    .modal {
2734
      width: min(840px, 100%);
2735
      max-height: calc(100dvh - 96px);
2736
      overflow: auto;
2737
      background: var(--panel);
2738
      border: 1px solid var(--line);
2739
      border-radius: 8px;
2740
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2741
    }
2742
    .modal-head {
2743
      position: sticky;
2744
      top: 0;
2745
      z-index: 1;
2746
      display: flex;
2747
      align-items: center;
2748
      justify-content: space-between;
2749
      gap: 12px;
2750
      padding: 12px 14px;
2751
      border-bottom: 1px solid var(--line);
2752
      background: #fafbfc;
2753
    }
2754
    .modal-head h2 { margin: 0; font-size: 14px; }
2755
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2756
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2757
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2758
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2759
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2760
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2761
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2762
      #message { max-width: 100%; }
2763
      .panel-head { align-items: stretch; flex-direction: column; }
2764
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2765
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2766
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2767
      .modal-backdrop { padding-top: 16px; }
2768
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2769
      .grid { grid-template-columns: 1fr; }
2770
      table { min-width: 760px; }
2771
      .table-wrap { overflow-x: auto; }
2772
    }
2773
  </style>
2774
</head>
Bogdan Timofte authored 6 days ago
2775
<body class="is-login">
Xdev Host Manager authored a week ago
2776

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

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

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

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

            
Bogdan Timofte authored 5 days ago
2897
      <section class="page" id="page-dns" data-page="dns" hidden>
2898
        <section class="toolbar">
2899
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2900
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2901
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2902
          <button id="write-tsv">Write local-hosts.tsv</button>
2903
        </section>
Xdev Host Manager authored a week ago
2904
      </section>
2905

            
Bogdan Timofte authored 5 days ago
2906
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2907
        <section class="panel">
2908
          <div class="panel-head">
2909
            <h2>Work Orders</h2>
2910
            <div class="stats" id="wo-stats"></div>
2911
          </div>
2912
          <div class="problems" id="work-orders"></div>
2913
        </section>
Xdev Host Manager authored a week ago
2914
      </section>
2915

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

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

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

            
Bogdan Timofte authored 4 days ago
3024
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3025
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3026
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3027
  </div>
Bogdan Timofte authored 6 days ago
3028

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

            
3033
    const $ = (id) => document.getElementById(id);
3034
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
3035
    const PAGE_PATHS = {
3036
      '/': 'overview',
3037
      '/overview': 'overview',
3038
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3039
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3040
      '/dns': 'dns',
3041
      '/work-orders': 'work-orders',
3042
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3043
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3044
    };
Xdev Host Manager authored a week ago
3045

            
Bogdan Timofte authored 4 days ago
3046
    function isAuthLost(error) {
3047
      return !!(error && error.authLost);
3048
    }
3049

            
3050
    function authLostError(message) {
3051
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3052
      error.authLost = true;
3053
      return error;
3054
    }
3055

            
3056
    function handleAuthLost(message) {
3057
      state.authenticated = false;
3058
      msg('');
3059
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3060
    }
3061

            
Bogdan Timofte authored 4 days ago
3062
    async function ensureAuthenticated(message) {
3063
      if (!state.authenticated) {
3064
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3065
        return false;
3066
      }
3067
      const session = await api('/api/session');
3068
      state.authenticated = session.authenticated;
3069
      if (!state.authenticated) {
3070
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3071
        return false;
3072
      }
3073
      return true;
3074
    }
3075

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

            
Bogdan Timofte authored 5 days ago
3096
    function currentPage() {
3097
      return PAGE_PATHS[window.location.pathname] || 'overview';
3098
    }
3099

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

            
Xdev Host Manager authored a week ago
3120
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3121
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3122
      document.body.classList.remove('is-app');
3123
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3124
      $('app').style.display = 'none';
3125
      $('login-screen').style.display = 'flex';
3126
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3127
      clearOtp();
Xdev Host Manager authored a week ago
3128
    }
3129

            
3130
    function showApp() {
Bogdan Timofte authored 6 days ago
3131
      document.body.classList.remove('is-login');
3132
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3133
      $('login-screen').style.display = 'none';
3134
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3135
      showPage(currentPage());
Xdev Host Manager authored a week ago
3136
    }
3137

            
Xdev Host Manager authored a week ago
3138
    async function refresh() {
3139
      const session = await api('/api/session');
3140
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3141
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3142
      showApp();
Xdev Host Manager authored a week ago
3143
      const data = await api('/api/hosts');
3144
      state.hosts = data.hosts || [];
3145
      state.problems = data.problems || [];
3146
      render(data);
Xdev Host Manager authored a week ago
3147
      await renderCa();
Xdev Host Manager authored a week ago
3148
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3149
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3150
    }
3151

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

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

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

            
3165
      renderHosts();
Bogdan Timofte authored 4 days ago
3166
      renderVhosts();
Xdev Host Manager authored a week ago
3167
    }
3168

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

            
Bogdan Timofte authored 5 days ago
3229
    function daysUntil(dateText) {
3230
      const time = Date.parse(dateText || '');
3231
      if (!Number.isFinite(time)) return null;
3232
      return Math.ceil((time - Date.now()) / 86400000);
3233
    }
3234

            
3235
    function certStatusClass(days) {
3236
      if (days === null) return '';
3237
      if (days < 0) return 'bad';
3238
      if (days <= 30) return 'warn';
3239
      return 'ok';
3240
    }
3241

            
3242
    function certStatusLabel(days) {
3243
      if (days === null) return 'validity unknown';
3244
      if (days < 0) return 'expired';
3245
      if (days === 0) return 'expires today';
3246
      return `${days}d remaining`;
3247
    }
3248

            
Xdev Host Manager authored a week ago
3249
    async function renderWorkOrders() {
3250
      try {
3251
        const data = await api('/api/work-orders');
3252
        state.workOrders = data.work_orders || [];
3253
        $('wo-stats').innerHTML = [
3254
          ['pending', data.counts.pending],
3255
          ['total', data.counts.work_orders],
3256
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3257

            
3258
        if (!state.workOrders.length) {
3259
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3260
          return;
3261
        }
3262

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

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

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

            
3352
    function debugTableReference(database, tableName) {
3353
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3354
    }
3355

            
3356
    async function selectDebugTable(tableName) {
3357
      state.debugTable = tableName || '';
3358
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3359
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3360
        const card = button.closest('.debug-table-card');
3361
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3362
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3363
      });
3364
      if (state.debugTable) await renderDebugTable(state.debugTable);
3365
    }
3366

            
3367
    function clearDebugTable() {
3368
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3369
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3370
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3371
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3372
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3373
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3374
    }
3375

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

            
Bogdan Timofte authored 4 days ago
3391
    function updateDebugExportLinks(tableName) {
3392
      const encoded = encodeURIComponent(tableName || '');
3393
      [
3394
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3395
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3396
      ].forEach(([id, href]) => {
3397
        const link = $(id);
3398
        const enabled = !!tableName;
3399
        link.href = enabled ? href : '#';
3400
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3401
      });
3402
    }
3403

            
Bogdan Timofte authored 4 days ago
3404
    function renderDebugRows(data) {
3405
      const rows = data.rows || [];
3406
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3407
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3408
    }
3409

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

            
3425
    function debugCell(value) {
3426
      if (value === null || value === undefined) return 'NULL';
3427
      if (Array.isArray(value)) return value.join(', ');
3428
      if (typeof value === 'object') return JSON.stringify(value);
3429
      return String(value);
3430
    }
3431

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

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

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

            
Bogdan Timofte authored 4 days ago
3492
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3493
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3494
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3495
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3496
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3497
    }
3498

            
3499
    function vhostRows() {
3500
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3501
        vhost,
3502
        host_id: host.id || '',
3503
        host_fqdn: host.fqdn || '',
3504
        ip: host.ip || '',
3505
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3506
        monitoring: host.monitoring || '',
3507
        status: host.status || '',
3508
      })));
3509
    }
3510

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

            
3536
    function shortAliasForFqdn(name) {
3537
      const suffix = '.madagascar.xdev.ro';
3538
      name = String(name || '').toLowerCase();
3539
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3540
    }
3541

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

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

            
3566
    function openHostModal(title) {
3567
      $('host-modal-title').textContent = title || 'Edit host';
3568
      $('host-modal').hidden = false;
3569
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3570
      hostFormSnapshot = hostFormState();
3571
      hostField('id').focus();
3572
    }
3573

            
3574
    function requestCloseHostModal() {
3575
      if ($('save-host').disabled) return;
3576
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3577
      closeHostModal();
Bogdan Timofte authored 5 days ago
3578
    }
3579

            
3580
    function closeHostModal() {
3581
      $('host-modal').hidden = true;
3582
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3583
      setHostFormBusy(false);
3584
      clearHostFormMessage();
3585
      hostFormSnapshot = '';
3586
    }
3587

            
3588
    function hostField(name) {
3589
      return $('host-form').elements.namedItem(name);
3590
    }
3591

            
3592
    function hostFormState() {
3593
      return JSON.stringify(formObject($('host-form')));
3594
    }
3595

            
3596
    function hostFormDirty() {
3597
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3598
    }
3599

            
3600
    function setHostFormBusy(busy) {
3601
      $('save-host').disabled = busy;
3602
      $('delete-host').disabled = busy;
3603
      $('close-host-modal').disabled = busy;
3604
    }
3605

            
3606
    function setHostFormMessage(text, isError = false) {
3607
      const message = $('host-form-message');
3608
      message.textContent = text || '';
3609
      message.classList.toggle('error', !!isError);
3610
    }
3611

            
3612
    function clearHostFormMessage() {
3613
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3614
    }
3615

            
3616
    function formObject(form) {
3617
      return Object.fromEntries(new FormData(form).entries());
3618
    }
3619

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

            
Bogdan Timofte authored 6 days ago
3625
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3626

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

            
3632
    if (loginAccount) {
3633
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3634
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3635
      loginAccount.addEventListener('input', () => {
3636
        const value = (loginAccount.value || '').trim();
3637
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3638
      });
3639
    }
3640

            
Xdev Host Manager authored a week ago
3641
    function setOtpDigit(idx, value) {
3642
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3643
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3644
      otpDigits[idx].classList.toggle('filled', !!digit);
3645
    }
3646

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

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

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

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

            
3703
      input.addEventListener('paste', (e) => {
3704
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3705
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3706
        const text = (e.clipboardData || window.clipboardData).getData('text');
3707
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3708
      });
Bogdan Timofte authored 4 days ago
3709

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

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

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

            
Bogdan Timofte authored 4 days ago
3743
    window.addEventListener('popstate', () => {
3744
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3745
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3746
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3747
    });
Bogdan Timofte authored 5 days ago
3748

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

            
3765
    $('copy-build').addEventListener('click', async () => {
3766
      try {
3767
        await copyText($('copy-build').dataset.buildDetails || '');
3768
        if (state.authenticated) msg('build details copied');
3769
      } catch (e) {
3770
        if (state.authenticated) msg('copy failed');
3771
      }
3772
    });
3773

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

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

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

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

            
3835
    $('host-form').addEventListener('invalid', (event) => {
3836
      setHostFormMessage('Complete the required host fields before saving.', true);
3837
    }, true);
3838

            
3839
    $('host-form').addEventListener('input', () => {
3840
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3841
    });
3842

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

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

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