LocalAuthority / scripts / host_manager.pl
Newer Older
3984 lines | 153.852kb
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
        }
Bogdan Timofte authored 4 days ago
205
        if ($path eq '/api/vhosts/reassign') {
206
            my $payload = request_payload(\%headers, $body);
207
            return reassign_vhost($client, $payload);
208
        }
Xdev Host Manager authored a week ago
209
        if ($path eq '/api/work-orders/confirm') {
210
            my $payload = request_payload(\%headers, $body);
211
            return confirm_work_order($client, $payload);
212
        }
Xdev Host Manager authored a week ago
213
        if ($path eq '/api/work-orders/checklist') {
214
            my $payload = request_payload(\%headers, $body);
215
            return update_work_order_checklist($client, $payload);
216
        }
Xdev Host Manager authored a week ago
217
        if ($path eq '/api/render/local-hosts-tsv') {
218
            my $registry = load_registry();
219
            my $content = render_local_hosts_tsv($registry);
220
            backup_file($opt{local_hosts_tsv});
221
            write_file($opt{local_hosts_tsv}, $content);
222
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
223
        }
224
    }
225

            
226
    return send_json($client, 404, { error => 'not_found' });
227
}
228

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

            
Xdev Host Manager authored a week ago
234
sub load_registry {
Bogdan Timofte authored 4 days ago
235
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
236
    normalize_registry_policy($registry);
237
    return $registry;
Xdev Host Manager authored a week ago
238
}
239

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

            
Xdev Host Manager authored a week ago
247
sub load_work_orders {
Bogdan Timofte authored 4 days ago
248
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
249
}
250

            
251
sub save_work_orders {
252
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
253
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
254
}
255

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

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

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

            
294
    my $registry = load_registry();
295
    my $results = apply_work_order($registry, $work_order);
296
    $work_order->{status} = 'confirmed';
297
    $work_order->{confirmed_at} = iso_now();
298
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
299

            
300
    save_registry($registry);
301
    save_work_orders($orders);
302
    backup_file($opt{local_hosts_tsv});
303
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
304

            
305
    return send_json($client, 200, {
306
        ok => json_bool(1),
307
        work_order => $work_order,
308
        results => $results,
309
        local_hosts_tsv => $opt{local_hosts_tsv},
310
    });
311
}
312

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

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

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

            
343
    $item->{status} = $status;
344
    $item->{updated_at} = iso_now();
345
    $item->{notes} = $notes if length $notes;
346
    save_work_orders($orders);
347
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
348
}
349

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

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

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

            
409
sub upsert_host {
410
    my ($client, $payload) = @_;
411
    my $id = clean_id($payload->{id} || '');
412
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
413

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

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

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

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

            
458
sub delete_host {
459
    my ($client, $id) = @_;
460
    $id = clean_id($id);
461
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
462

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

            
Bogdan Timofte authored 4 days ago
471
sub reassign_vhost {
472
    my ($client, $payload) = @_;
473
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
474
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
475
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
476
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
477

            
478
    my $dbh = dbh();
479
    my ($current_fqdn) = $dbh->selectrow_array(
480
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
481
        undef,
482
        $vhost,
483
    );
484
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
485
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
486
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
487

            
488
    my $result = eval {
489
        with_transaction($dbh, sub {
490
            my $now = iso_now();
491
            $dbh->do(
492
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
493
                undef,
494
                $target_fqdn, $now, $vhost,
495
            );
496

            
497
            my $registry = load_registry_from_db();
498
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
499
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
500

            
501
            upsert_host_to_db($dbh, $target_host) if $target_host;
502
            upsert_host_to_db($dbh, $current_host) if $current_host;
503
        });
504
        1;
505
    };
506
    if (!$result) {
507
        my $err = $@ || 'vhost_reassign_failed';
508
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
509
    }
510
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
511
}
512

            
Xdev Host Manager authored a week ago
513
sub analyze_hosts {
514
    my ($hosts) = @_;
515
    my @problems;
516
    my (%names, %ids);
517
    for my $host (@$hosts) {
518
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
519
        my $fqdn = canonical_host_fqdn($host);
520
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
521
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
522
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
523
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
524
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
525
            if grep { /^(is|vad|b)-/ } @declared;
526
        for my $name (@declared) {
Xdev Host Manager authored a week ago
527
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
528
        }
Bogdan Timofte authored 4 days ago
529
        my %declared = map { $_ => 1 } @declared;
530
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
531
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
532
                if $declared{$derived};
533
        }
Bogdan Timofte authored 4 days ago
534
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
535
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
536
    }
537
    return \@problems;
538
}
539

            
Xdev Host Manager authored a week ago
540
sub host_payload {
541
    my ($host) = @_;
542
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
543
    $copy{fqdn} = canonical_host_fqdn($host);
544
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
545
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
546
    $copy{declared_names} = [ declared_dns_names($host) ];
547
    $copy{aliases} = [ declared_alias_names($host) ];
548
    $copy{derived_aliases} = [ derived_alias_names($host) ];
549
    $copy{vhosts} = [ declared_vhost_names($host) ];
550
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Xdev Host Manager authored a week ago
551
    return \%copy;
552
}
553

            
554
sub effective_names {
555
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
556
    my @names = declared_dns_names($host);
557
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
558
    return unique_preserve(@names);
559
}
560

            
Bogdan Timofte authored 4 days ago
561
sub declared_dns_names {
562
    my ($host) = @_;
563
    my @names;
564
    my $fqdn = canonical_host_fqdn($host);
565
    push @names, $fqdn if length $fqdn;
566
    push @names, declared_alias_names($host);
567
    push @names, declared_vhost_names($host);
568
    return unique_preserve(@names);
569
}
570

            
571
sub declared_alias_names {
572
    my ($host) = @_;
573
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
574
}
575

            
576
sub declared_vhost_names {
577
    my ($host) = @_;
578
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
579
}
580

            
581
sub declared_dns_names_legacy {
582
    my ($host) = @_;
583
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
584
}
585

            
586
sub split_legacy_names {
587
    my ($id, $names) = @_;
588
    my $fallback = clean_id($id || '');
589
    my (%result) = (
590
        fqdn => '',
591
        aliases => [],
592
        vhosts => [],
593
    );
594
    for my $name (map { normalize_dns_name($_) } @$names) {
595
        next unless length $name;
596
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
597
            $result{fqdn} = $name;
598
            next;
599
        }
600
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
601
            $result{fqdn} = $name;
602
            next;
603
        }
604
        if (name_is_vhost($name)) {
605
            push @{ $result{vhosts} }, $name;
606
        } else {
607
            push @{ $result{aliases} }, $name;
608
        }
609
    }
610
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
611
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
612
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
613
    return \%result;
614
}
615

            
616
sub derived_alias_names {
Xdev Host Manager authored a week ago
617
    my ($host) = @_;
618
    my @derived;
Bogdan Timofte authored 4 days ago
619
    my $fqdn = canonical_host_fqdn($host);
620
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
621
    for my $name (declared_alias_names($host)) {
622
        push @derived, short_alias_for_fqdn($name);
623
    }
624
    return unique_preserve(grep { length $_ } @derived);
625
}
626

            
627
sub derived_vhost_alias_names {
628
    my ($host) = @_;
629
    my @derived;
630
    for my $name (declared_vhost_names($host)) {
631
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
632
    }
Bogdan Timofte authored 4 days ago
633
    return unique_preserve(grep { length $_ } @derived);
634
}
635

            
636
sub clean_alias_names {
637
    my ($payload) = @_;
638
    return clean_name_bucket($payload->{aliases})
639
        if defined $payload->{aliases};
640
    my @legacy = remove_derived_names(clean_list($payload->{names}));
641
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
642
}
643

            
644
sub clean_vhost_names {
645
    my ($payload) = @_;
646
    return clean_name_bucket($payload->{vhosts})
647
        if defined $payload->{vhosts};
648
    my @legacy = remove_derived_names(clean_list($payload->{names}));
649
    return grep { name_is_vhost($_) } @legacy;
650
}
651

            
652
sub clean_name_bucket {
653
    my ($value) = @_;
654
    my @names = clean_list($value);
655
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
656
}
657

            
658
sub remove_derived_names {
659
    my @names = @_;
660
    my %derived;
661
    for my $name (@names) {
662
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
663
        $derived{$1} = 1;
664
    }
665
    return grep { !$derived{$_} } @names;
666
}
667

            
668
sub unique_preserve {
669
    my @values = @_;
670
    my %seen;
671
    return grep { !$seen{$_}++ } @values;
672
}
673

            
Bogdan Timofte authored 4 days ago
674
sub canonical_ip {
675
    my ($host) = @_;
676
    return '' unless $host && ref($host) eq 'HASH';
677
    for my $key (qw(ip dns_ip hosts_ip)) {
678
        my $value = clean_scalar($host->{$key} || '');
679
        return $value if length $value;
680
    }
681
    return '';
682
}
683

            
Xdev Host Manager authored a week ago
684
sub problem {
685
    my ($host, $code, $message) = @_;
686
    return { host_id => $host->{id}, code => $code, message => $message };
687
}
688

            
689
sub render_local_hosts_tsv {
690
    my ($registry) = @_;
691
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
692
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
693
    $out .= "#\n";
694
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
695
    $out .= "# ip<TAB>name [aliases...]\n";
Xdev Host Manager authored a week ago
696
    $out .= "#\n";
697
    $out .= "# Priority rule:\n";
698
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
699
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
700
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
701
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
702
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
703
        my $ip = canonical_ip($host);
704
        next unless $ip;
Xdev Host Manager authored a week ago
705
        my @names = effective_names($host);
706
        next unless @names;
Bogdan Timofte authored 4 days ago
707
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
708
    }
709
    return $out;
710
}
711

            
712
sub render_monitoring {
713
    my ($registry) = @_;
714
    my @hosts;
715
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
716
        next unless ($host->{status} || 'active') eq 'active';
717
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
718
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
719
        push @hosts, {
720
            id => $host->{id},
Xdev Host Manager authored a week ago
721
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
722
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
723
            aliases => \@names,
Bogdan Timofte authored 4 days ago
724
            fqdn => canonical_host_fqdn($host),
725
            declared_names => [ declared_dns_names($host) ],
726
            aliases_declared => [ declared_alias_names($host) ],
727
            aliases_derived => [ derived_alias_names($host) ],
728
            vhosts_declared => [ declared_vhost_names($host) ],
729
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
730
            roles => [ @{ $host->{roles} || [] } ],
731
            monitoring => $host->{monitoring} || 'pending',
732
            notes => $host->{notes} || '',
733
        };
734
    }
735
    return {
736
        version => $registry->{version},
737
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
738
        source => $opt{db},
Xdev Host Manager authored a week ago
739
        hosts => \@hosts,
740
    };
741
}
742

            
Bogdan Timofte authored 4 days ago
743
sub debug_database_tables_payload {
744
    my $dbh = dbh();
745
    my @tables;
746
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
747
    $sth->execute;
748
    while (my ($name) = $sth->fetchrow_array) {
749
        my $quoted = $dbh->quote_identifier($name);
750
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
751
        push @tables, {
752
            name => $name,
753
            rows => int($count || 0),
754
        };
755
    }
756
    return {
757
        database => $opt{db},
758
        generated_at => iso_now(),
759
        tables => \@tables,
760
        counts => {
761
            tables => scalar @tables,
762
            rows => sum(map { $_->{rows} } @tables),
763
        },
764
    };
765
}
766

            
767
sub debug_database_table_payload {
768
    my ($table, $limit) = @_;
769
    my $dbh = dbh();
770
    $table = clean_scalar($table);
771
    return { error => 'missing_table' } unless length $table;
772
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
773
    $limit = int($limit || 100);
774
    $limit = 1 if $limit < 1;
775
    $limit = 500 if $limit > 500;
776

            
777
    my $quoted = $dbh->quote_identifier($table);
778
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
779
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
780
    my @index_details;
781
    for my $index (@$indexes) {
782
        my $index_name = $index->{name} || '';
783
        next unless length $index_name;
784
        my $quoted_index = $dbh->quote_identifier($index_name);
785
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
786
        push @index_details, {
787
            name => $index_name,
788
            unique => int($index->{unique} || 0),
789
            origin => $index->{origin} || '',
790
            partial => int($index->{partial} || 0),
791
            columns => [ map { $_->{name} || '' } @$index_columns ],
792
        };
793
    }
794
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
795
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
796
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
797

            
798
    return {
799
        database => $opt{db},
800
        table => $table,
801
        generated_at => iso_now(),
802
        limit => $limit,
803
        row_count => int($row_count || 0),
804
        columns => $columns,
805
        indexes => \@index_details,
806
        foreign_keys => $foreign_keys,
807
        rows => $rows,
808
    };
809
}
810

            
Bogdan Timofte authored 4 days ago
811
sub debug_database_table_export_payload {
812
    my ($table) = @_;
813
    my $dbh = dbh();
814
    $table = clean_scalar($table);
815
    return { error => 'missing_table' } unless length $table;
816
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
817

            
818
    my $quoted = $dbh->quote_identifier($table);
819
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
820
    my @column_names = map { $_->{name} || '' } @$columns;
821
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
822
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
823

            
824
    return {
825
        database => $opt{db},
826
        table => $table,
827
        generated_at => iso_now(),
828
        row_count => int($row_count || 0),
829
        columns => \@column_names,
830
        rows => $rows,
831
    };
832
}
833

            
834
sub render_debug_table_csv {
835
    my ($export) = @_;
836
    my @columns = @{ $export->{columns} || [] };
837
    my @lines = (join(',', map { csv_cell($_) } @columns));
838
    for my $row (@{ $export->{rows} || [] }) {
839
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
840
    }
841
    return join("\n", @lines) . "\n";
842
}
843

            
844
sub csv_cell {
845
    my ($value) = @_;
846
    $value = '' unless defined $value;
847
    $value = "$value";
848
    $value =~ s/"/""/g;
849
    return qq("$value") if $value =~ /[",\r\n]/;
850
    return $value;
851
}
852

            
853
sub debug_table_export_filename {
854
    my ($table, $extension) = @_;
855
    $table = clean_scalar($table || 'table');
856
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
857
    $table = 'table' unless length $table;
858
    return "debug-$table.$extension";
859
}
860

            
Bogdan Timofte authored 4 days ago
861
sub debug_table_exists {
862
    my ($dbh, $table) = @_;
863
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
864
    my ($exists) = $dbh->selectrow_array(
865
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
866
        undef,
867
        $table,
868
    );
869
    return $exists ? 1 : 0;
870
}
871

            
872
sub sum {
873
    my $total = 0;
874
    $total += $_ || 0 for @_;
875
    return $total;
876
}
877

            
Xdev Host Manager authored a week ago
878
sub ca_script_path {
879
    return "$project_dir/scripts/ca_manager.sh";
880
}
881

            
882
sub ca_dir {
883
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
884
}
885

            
886
sub ca_cert_path {
887
    return ca_dir() . "/certs/ca.cert.pem";
888
}
889

            
Bogdan Timofte authored 5 days ago
890
sub ca_issued_cert_path {
891
    my ($name) = @_;
892
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
893
    return ca_dir() . "/issued/$name.cert.pem";
894
}
895

            
Xdev Host Manager authored a week ago
896
sub ca_manager_json {
897
    my ($command) = @_;
898
    my $script = ca_script_path();
899
    die "CA manager script is missing\n" unless -x $script;
900
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
901
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
902
    local $/;
903
    my $out = <$fh>;
904
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
905
    $out ||= $command eq 'list-json' ? '[]' : '{}';
906
    sync_certificates_from_json($out) if $command eq 'list-json';
907
    return $out;
908
}
909

            
910
sub sync_certificates_from_json {
911
    my ($json) = @_;
912
    my $certs = eval { json_decode($json || '[]') };
913
    return if $@ || ref($certs) ne 'ARRAY';
914
    my $dbh = dbh();
915
    my $now = iso_now();
916
    with_transaction($dbh, sub {
917
        for my $cert (@$certs) {
918
            next unless ref($cert) eq 'HASH';
919
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
920
            next unless $name;
921
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
922
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
923
            my $cert_path = ca_issued_cert_path($name);
924
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
925
            my $serial = clean_scalar($cert->{serial} || '');
926
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
927
            $dbh->do(
928
                '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) '
929
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
930
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
931
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
932
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
933
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
934
                undef,
935
                $name,
936
                $host_fqdn || undef,
937
                $dns_names[0] || '',
938
                clean_scalar($cert->{subject} || ''),
939
                clean_scalar($cert->{issuer} || ''),
940
                length($serial) ? $serial : undef,
941
                clean_scalar($cert->{not_before} || ''),
942
                clean_scalar($cert->{not_after} || ''),
943
                length($fingerprint) ? $fingerprint : undef,
944
                $cert_path,
945
                $csr_path,
946
                $now,
947
                $now,
948
            );
949
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
950
            for my $dns_name (@dns_names) {
951
                next unless length $dns_name;
952
                $dbh->do(
953
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
954
                    undef,
955
                    $name,
956
                    $dns_name,
957
                );
958
            }
959
        }
960
    });
961
}
962

            
963
sub infer_certificate_host_fqdn {
964
    my ($dbh, $dns_names) = @_;
965
    for my $name (@$dns_names) {
966
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
967
        return $fqdn if $fqdn;
968
    }
969
    for my $name (@$dns_names) {
970
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
971
        return $fqdn if $fqdn;
972
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
973
        return $fqdn if $fqdn;
974
    }
975
    return '';
Xdev Host Manager authored a week ago
976
}
977

            
Xdev Host Manager authored a week ago
978
sub parse_hosts_yaml {
979
    my ($text) = @_;
980
    my %registry = (
981
        version => 1,
982
        updated_at => '',
983
        policy => {},
984
        hosts => [],
985
    );
986
    my ($section, $current, $list_key);
987
    for my $line (split /\n/, $text) {
988
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
989
        if ($line =~ /^version:\s*(\d+)/) {
990
            $registry{version} = int($1);
991
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
992
            $registry{updated_at} = yaml_unquote($1);
993
        } elsif ($line =~ /^policy:\s*$/) {
994
            $section = 'policy';
995
        } elsif ($line =~ /^hosts:\s*$/) {
996
            $section = 'hosts';
997
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
998
            $registry{policy}{$1} = yaml_unquote($2);
999
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1000
            $current = {
1001
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1002
                fqdn => '',
Xdev Host Manager authored a week ago
1003
                status => 'active',
Bogdan Timofte authored 4 days ago
1004
                ip => '',
1005
                aliases => [],
1006
                vhosts => [],
Xdev Host Manager authored a week ago
1007
                roles => [],
1008
                sources => [],
1009
                monitoring => 'pending',
1010
                notes => '',
1011
            };
1012
            push @{ $registry{hosts} }, $current;
1013
            $list_key = undef;
1014
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1015
            $list_key = $1;
1016
            $current->{$list_key} ||= [];
1017
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1018
            push @{ $current->{$list_key} }, yaml_unquote($1);
1019
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1020
            my $key = $1;
1021
            my $value = yaml_unquote($2);
1022
            if ($key eq 'ip') {
1023
                $current->{ip} = $value;
1024
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1025
                $current->{ip} ||= $value;
1026
            } elsif ($key eq 'fqdn') {
1027
                $current->{fqdn} = normalize_dns_name($value);
1028
            } elsif ($key eq 'names') {
1029
                # ignored here; legacy list is handled after parsing
1030
            } else {
1031
                $current->{$key} = $value;
1032
            }
Xdev Host Manager authored a week ago
1033
            $list_key = undef;
1034
        }
1035
    }
Bogdan Timofte authored 4 days ago
1036
    for my $host (@{ $registry{hosts} }) {
1037
        my @legacy_names = @{ $host->{names} || [] };
1038
        if (@legacy_names) {
1039
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1040
            $host->{fqdn} ||= $legacy->{fqdn};
1041
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1042
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1043
        }
1044
        delete $host->{names};
1045
        $host->{fqdn} ||= canonical_host_fqdn($host);
1046
    }
Xdev Host Manager authored a week ago
1047
    return \%registry;
1048
}
1049

            
1050
sub render_hosts_yaml {
1051
    my ($registry) = @_;
1052
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1053
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1054
    $out .= "policy:\n";
1055
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1056
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1057
    }
1058
    $out .= "hosts:\n";
1059
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1060
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1061
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1062
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1063
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1064
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1065
            $out .= "    $key:\n";
1066
            for my $value (@{ $host->{$key} || [] }) {
1067
                $out .= "      - " . yq($value) . "\n";
1068
            }
1069
        }
1070
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1071
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1072
    }
1073
    return $out;
1074
}
1075

            
Xdev Host Manager authored a week ago
1076
sub parse_work_orders_yaml {
1077
    my ($text) = @_;
1078
    my %orders = (
1079
        version => 1,
1080
        work_orders => [],
1081
    );
Xdev Host Manager authored a week ago
1082
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1083
    for my $line (split /\n/, $text) {
1084
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1085
        if ($line =~ /^version:\s*(\d+)/) {
1086
            $orders{version} = int($1);
1087
        } elsif ($line =~ /^work_orders:\s*$/) {
1088
            $section = 'work_orders';
1089
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1090
            $current = {
1091
                id => yaml_unquote($1),
1092
                status => 'pending',
Xdev Host Manager authored a week ago
1093
                checklist => [],
Xdev Host Manager authored a week ago
1094
                actions => [],
1095
            };
1096
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1097
            $list_section = '';
Xdev Host Manager authored a week ago
1098
            $current_action = undef;
Xdev Host Manager authored a week ago
1099
            $current_item = undef;
1100
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1101
            $list_section = 'checklist';
1102
            $current->{checklist} ||= [];
1103
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1104
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1105
            push @{ $current->{checklist} }, $current_item;
1106
            $current_action = undef;
1107
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1108
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1109
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1110
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1111
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1112
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1113
            $current_action = { type => yaml_unquote($1) };
1114
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1115
            $current_item = undef;
1116
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1117
            $current_action->{$1} = yaml_unquote($2);
1118
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1119
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1120
            $list_section = '';
Xdev Host Manager authored a week ago
1121
            $current_action = undef;
Xdev Host Manager authored a week ago
1122
            $current_item = undef;
Xdev Host Manager authored a week ago
1123
        }
1124
    }
1125
    return \%orders;
1126
}
1127

            
1128
sub render_work_orders_yaml {
1129
    my ($orders) = @_;
1130
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1131
    $out .= "work_orders:\n";
1132
    for my $wo (@{ $orders->{work_orders} || [] }) {
1133
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1134
        for my $key (qw(status title reason created_at confirmed_at result)) {
1135
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1136
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1137
        }
Xdev Host Manager authored a week ago
1138
        $out .= "    checklist:\n";
1139
        for my $item (@{ $wo->{checklist} || [] }) {
1140
            $out .= "      - id: " . yq($item->{id}) . "\n";
1141
            for my $key (qw(text status owner notes updated_at)) {
1142
                next unless exists $item->{$key} && length($item->{$key} || '');
1143
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1144
            }
1145
        }
Xdev Host Manager authored a week ago
1146
        $out .= "    actions:\n";
1147
        for my $action (@{ $wo->{actions} || [] }) {
1148
            $out .= "      - type: " . yq($action->{type}) . "\n";
1149
            for my $key (qw(host_id name)) {
1150
                next unless exists $action->{$key} && length($action->{$key} || '');
1151
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1152
            }
1153
        }
1154
    }
1155
    return $out;
1156
}
1157

            
Xdev Host Manager authored a week ago
1158
sub request_payload {
1159
    my ($headers, $body) = @_;
1160
    my $type = $headers->{'content-type'} || '';
1161
    if ($type =~ m{application/json}) {
1162
        return json_decode($body || '{}');
1163
    }
1164
    return { parse_params($body || '') };
1165
}
1166

            
1167
sub json_bool {
1168
    my ($value) = @_;
1169
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1170
}
1171

            
1172
sub json_encode {
1173
    my ($value) = @_;
1174
    if (!defined $value) {
1175
        return 'null';
1176
    }
1177
    my $ref = ref($value);
1178
    if (!$ref) {
1179
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1180
        return json_string($value);
1181
    }
1182
    if ($ref eq 'HostManager::JSONBool') {
1183
        return $$value ? 'true' : 'false';
1184
    }
1185
    if ($ref eq 'ARRAY') {
1186
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1187
    }
1188
    if ($ref eq 'HASH') {
1189
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1190
    }
1191
    return json_string("$value");
1192
}
1193

            
1194
sub json_string {
1195
    my ($value) = @_;
1196
    $value = '' unless defined $value;
1197
    $value =~ s/\\/\\\\/g;
1198
    $value =~ s/"/\\"/g;
1199
    $value =~ s/\n/\\n/g;
1200
    $value =~ s/\r/\\r/g;
1201
    $value =~ s/\t/\\t/g;
1202
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1203
    return qq("$value");
1204
}
1205

            
1206
sub json_decode {
1207
    my ($text) = @_;
1208
    my $i = 0;
1209
    my $len = length($text);
1210
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1211

            
1212
    $skip_ws = sub {
1213
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1214
    };
1215

            
1216
    $parse_string = sub {
1217
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1218
        $i++;
1219
        my $out = '';
1220
        while ($i < $len) {
1221
            my $ch = substr($text, $i++, 1);
1222
            return $out if $ch eq '"';
1223
            if ($ch eq "\\") {
1224
                die "Bad JSON escape\n" if $i >= $len;
1225
                my $esc = substr($text, $i++, 1);
1226
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1227
                    $out .= $esc;
1228
                } elsif ($esc eq 'b') {
1229
                    $out .= "\b";
1230
                } elsif ($esc eq 'f') {
1231
                    $out .= "\f";
1232
                } elsif ($esc eq 'n') {
1233
                    $out .= "\n";
1234
                } elsif ($esc eq 'r') {
1235
                    $out .= "\r";
1236
                } elsif ($esc eq 't') {
1237
                    $out .= "\t";
1238
                } elsif ($esc eq 'u') {
1239
                    my $hex = substr($text, $i, 4);
1240
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1241
                    $out .= chr(hex($hex));
1242
                    $i += 4;
1243
                } else {
1244
                    die "Bad JSON escape\n";
1245
                }
1246
            } else {
1247
                $out .= $ch;
1248
            }
1249
        }
1250
        die "Unterminated JSON string\n";
1251
    };
1252

            
1253
    $parse_number = sub {
1254
        my $start = $i;
1255
        $i++ if substr($text, $i, 1) eq '-';
1256
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1257
        if ($i < $len && substr($text, $i, 1) eq '.') {
1258
            $i++;
1259
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1260
        }
1261
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1262
            $i++;
1263
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1264
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1265
        }
1266
        return 0 + substr($text, $start, $i - $start);
1267
    };
1268

            
1269
    $parse_array = sub {
1270
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1271
        $i++;
1272
        my @out;
1273
        $skip_ws->();
1274
        if ($i < $len && substr($text, $i, 1) eq ']') {
1275
            $i++;
1276
            return \@out;
1277
        }
1278
        while (1) {
1279
            push @out, $parse_value->();
1280
            $skip_ws->();
1281
            my $ch = substr($text, $i++, 1);
1282
            last if $ch eq ']';
1283
            die "Expected JSON array comma\n" unless $ch eq ',';
1284
        }
1285
        return \@out;
1286
    };
1287

            
1288
    $parse_object = sub {
1289
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1290
        $i++;
1291
        my %out;
1292
        $skip_ws->();
1293
        if ($i < $len && substr($text, $i, 1) eq '}') {
1294
            $i++;
1295
            return \%out;
1296
        }
1297
        while (1) {
1298
            $skip_ws->();
1299
            my $key = $parse_string->();
1300
            $skip_ws->();
1301
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1302
            $out{$key} = $parse_value->();
1303
            $skip_ws->();
1304
            my $ch = substr($text, $i++, 1);
1305
            last if $ch eq '}';
1306
            die "Expected JSON object comma\n" unless $ch eq ',';
1307
        }
1308
        return \%out;
1309
    };
1310

            
1311
    $parse_value = sub {
1312
        $skip_ws->();
1313
        die "Unexpected end of JSON\n" if $i >= $len;
1314
        my $ch = substr($text, $i, 1);
1315
        return $parse_string->() if $ch eq '"';
1316
        return $parse_object->() if $ch eq '{';
1317
        return $parse_array->() if $ch eq '[';
1318
        if (substr($text, $i, 4) eq 'true') {
1319
            $i += 4;
1320
            return json_bool(1);
1321
        }
1322
        if (substr($text, $i, 5) eq 'false') {
1323
            $i += 5;
1324
            return json_bool(0);
1325
        }
1326
        if (substr($text, $i, 4) eq 'null') {
1327
            $i += 4;
1328
            return undef;
1329
        }
1330
        return $parse_number->() if $ch =~ /[-0-9]/;
1331
        die "Unexpected JSON token\n";
1332
    };
1333

            
1334
    my $value = $parse_value->();
1335
    $skip_ws->();
1336
    die "Trailing JSON content\n" if $i != $len;
1337
    return $value;
1338
}
1339

            
1340
sub parse_params {
1341
    my ($text) = @_;
1342
    my %out;
1343
    for my $pair (split /&/, $text) {
1344
        next unless length $pair;
1345
        my ($k, $v) = split /=/, $pair, 2;
1346
        $out{url_decode($k)} = url_decode($v || '');
1347
    }
1348
    return %out;
1349
}
1350

            
1351
sub clean_id {
1352
    my ($value) = @_;
1353
    $value = lc clean_scalar($value);
1354
    $value =~ s/[^a-z0-9_.-]+/-/g;
1355
    $value =~ s/^-+|-+$//g;
1356
    return $value;
1357
}
1358

            
1359
sub clean_scalar {
1360
    my ($value) = @_;
1361
    $value = '' unless defined $value;
1362
    $value =~ s/[\r\n\t]+/ /g;
1363
    $value =~ s/^\s+|\s+$//g;
1364
    return $value;
1365
}
1366

            
1367
sub clean_list {
1368
    my ($value) = @_;
1369
    return () unless defined $value;
1370
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1371
    my @clean;
1372
    for my $item (@items) {
1373
        $item = clean_scalar($item);
1374
        push @clean, $item if length $item;
1375
    }
1376
    return @clean;
1377
}
1378

            
1379
sub yq {
1380
    my ($value) = @_;
1381
    $value = '' unless defined $value;
1382
    $value =~ s/\\/\\\\/g;
1383
    $value =~ s/"/\\"/g;
1384
    return qq("$value");
1385
}
1386

            
1387
sub yaml_unquote {
1388
    my ($value) = @_;
1389
    $value = '' unless defined $value;
1390
    $value =~ s/^\s+|\s+$//g;
1391
    if ($value =~ /^"(.*)"$/) {
1392
        $value = $1;
1393
        $value =~ s/\\"/"/g;
1394
        $value =~ s/\\\\/\\/g;
1395
    }
1396
    return $value;
1397
}
1398

            
1399
sub verify_totp {
1400
    my ($secret, $otp) = @_;
1401
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1402
    my $key = eval { base32_decode($secret) };
1403
    return 0 if $@ || !length $key;
1404
    my $counter = int(time() / 30);
1405
    for my $offset (-1, 0, 1) {
1406
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1407
    }
1408
    return 0;
1409
}
1410

            
1411
sub totp_code {
1412
    my ($key, $counter) = @_;
1413
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1414
    my $hash = hmac_sha1($msg, $key);
1415
    my $offset = ord(substr($hash, -1)) & 0x0f;
1416
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1417
    return sprintf('%06d', $bin % 1_000_000);
1418
}
1419

            
1420
sub base32_decode {
1421
    my ($text) = @_;
1422
    $text = uc($text || '');
1423
    $text =~ s/[^A-Z2-7]//g;
1424
    my %map;
1425
    my @chars = ('A'..'Z', '2'..'7');
1426
    @map{@chars} = (0..31);
1427
    my ($bits, $value, $out) = (0, 0, '');
1428
    for my $char (split //, $text) {
1429
        die "Invalid base32\n" unless exists $map{$char};
1430
        $value = ($value << 5) | $map{$char};
1431
        $bits += 5;
1432
        while ($bits >= 8) {
1433
            $bits -= 8;
1434
            $out .= chr(($value >> $bits) & 0xff);
1435
        }
1436
    }
1437
    return $out;
1438
}
1439

            
1440
sub create_session {
1441
    my $nonce = random_hex(24);
1442
    my $expires = int(time() + 8 * 3600);
1443
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1444
    my $token = "$nonce:$expires:$sig";
1445
    $sessions{$token} = $expires;
1446
    return $token;
1447
}
1448

            
1449
sub is_authenticated {
1450
    my ($headers) = @_;
1451
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1452
    return 0 unless $token;
1453
    my ($nonce, $expires, $sig) = split /:/, $token;
1454
    return 0 unless $nonce && $expires && $sig;
1455
    return 0 if $expires < time();
1456
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1457
    return exists $sessions{$token};
1458
}
1459

            
1460
sub expire_session {
1461
    my ($headers) = @_;
1462
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1463
    delete $sessions{$token} if $token;
1464
}
1465

            
1466
sub cookie_value {
1467
    my ($cookie, $name) = @_;
1468
    for my $part (split /;\s*/, $cookie) {
1469
        my ($k, $v) = split /=/, $part, 2;
1470
        return $v if defined $k && $k eq $name;
1471
    }
1472
    return '';
1473
}
1474

            
1475
sub send_json {
1476
    my ($client, $status, $payload, $extra_headers) = @_;
1477
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1478
}
1479

            
Xdev Host Manager authored a week ago
1480
sub send_json_raw {
1481
    my ($client, $status, $json_body, $extra_headers) = @_;
1482
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1483
}
1484

            
Xdev Host Manager authored a week ago
1485
sub send_html {
1486
    my ($client, $status, $html) = @_;
1487
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1488
}
1489

            
1490
sub send_text {
1491
    my ($client, $status, $text) = @_;
1492
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1493
}
1494

            
1495
sub send_download {
1496
    my ($client, $status, $content, $type, $filename) = @_;
1497
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1498
}
1499

            
1500
sub send_file {
1501
    my ($client, $path, $type, $filename) = @_;
1502
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1503
    return send_download($client, 200, read_file($path), $type, $filename);
1504
}
1505

            
1506
sub send_response {
1507
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1508
    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
1509
    $body = '' unless defined $body;
1510
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1511
    print $client "Content-Type: $type\r\n";
1512
    print $client "Content-Length: " . length($body) . "\r\n";
1513
    print $client "Cache-Control: no-store\r\n";
1514
    print $client "$_\r\n" for @{ $extra_headers || [] };
1515
    print $client "Connection: close\r\n\r\n";
1516
    print $client $body;
1517
}
1518

            
1519
sub read_file {
1520
    my ($path) = @_;
1521
    open my $fh, '<', $path or die "Cannot read $path: $!";
1522
    local $/;
1523
    return <$fh>;
1524
}
1525

            
1526
sub write_file {
1527
    my ($path, $content) = @_;
1528
    open my $fh, '>', $path or die "Cannot write $path: $!";
1529
    print {$fh} $content;
1530
    close $fh or die "Cannot close $path: $!";
1531
}
1532

            
1533
sub backup_file {
1534
    my ($path) = @_;
1535
    return unless -f $path;
1536
    my $backup_dir = "$project_dir/backups/host-manager";
1537
    make_path($backup_dir) unless -d $backup_dir;
1538
    my $name = $path;
1539
    $name =~ s{.*/}{};
1540
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1541
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1542
}
1543

            
Bogdan Timofte authored 4 days ago
1544
my $db_handle;
Bogdan Timofte authored 4 days ago
1545
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1546

            
1547
sub dbh {
1548
    return $db_handle if $db_handle;
1549
    ensure_parent_dir($opt{db});
1550
    $db_handle = DBI->connect(
1551
        "dbi:SQLite:dbname=$opt{db}",
1552
        '',
1553
        '',
1554
        {
1555
            RaiseError => 1,
1556
            PrintError => 0,
1557
            AutoCommit => 1,
1558
            sqlite_unicode => 1,
1559
        },
1560
    ) or die "Cannot open SQLite database $opt{db}\n";
1561
    $db_handle->do('PRAGMA journal_mode = WAL');
1562
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1563
    create_database_schema($db_handle);
1564
    seed_database($db_handle) unless $db_seeded++;
1565
    return $db_handle;
1566
}
1567

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

            
Bogdan Timofte authored 4 days ago
1827
sub seed_database {
1828
    my ($dbh) = @_;
1829
    seed_default_workers($dbh);
1830

            
1831
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1832
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1833
        normalize_registry_policy($registry);
1834
        with_transaction($dbh, sub {
1835
            import_registry_to_db($dbh, $registry, 0);
1836
        });
1837
    }
1838

            
1839
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1840
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1841
        with_transaction($dbh, sub {
1842
            import_work_orders_to_db($dbh, $orders);
1843
        });
1844
    }
1845

            
1846
    seed_mdns_observations_from_yaml($dbh);
1847
}
1848

            
1849
sub with_transaction {
1850
    my ($dbh, $code) = @_;
1851
    return $code->() unless $dbh->{AutoCommit};
1852
    $dbh->begin_work;
1853
    my $ok = eval {
1854
        $code->();
1855
        1;
1856
    };
1857
    if (!$ok) {
1858
        my $err = $@ || 'transaction failed';
1859
        eval { $dbh->rollback };
1860
        die $err;
1861
    }
1862
    $dbh->commit;
1863
}
1864

            
1865
sub db_scalar {
1866
    my ($dbh, $sql, @bind) = @_;
1867
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1868
    return $value || 0;
1869
}
1870

            
1871
sub legacy_document_text {
1872
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1873
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1874
    return $row->{content} if $row && defined $row->{content};
1875
    return read_file($seed_path) if -f $seed_path;
1876
    return $default_text;
1877
}
1878

            
1879
sub load_registry_from_db {
1880
    my $dbh = dbh();
1881
    my $registry = {
1882
        version => 1,
1883
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1884
        policy => {},
1885
        hosts => [],
1886
    };
Bogdan Timofte authored 4 days ago
1887

            
Bogdan Timofte authored 4 days ago
1888
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1889
    $sth->execute;
1890
    while (my $row = $sth->fetchrow_hashref) {
1891
        my $fqdn = $row->{fqdn};
1892
        push @{ $registry->{hosts} }, {
1893
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
1894
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
1895
            status => $row->{status},
Bogdan Timofte authored 4 days ago
1896
            ip => canonical_ip($row),
1897
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
1898
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
1899
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1900
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1901
            monitoring => $row->{monitoring},
1902
            notes => $row->{notes},
1903
        };
1904
    }
1905

            
1906
    return $registry;
Bogdan Timofte authored 4 days ago
1907
}
1908

            
Bogdan Timofte authored 4 days ago
1909
sub save_registry_to_db {
1910
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1911
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1912
    with_transaction($dbh, sub {
1913
        import_registry_to_db($dbh, $registry, 1);
1914
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1915
    });
1916
}
1917

            
1918
sub import_registry_to_db {
1919
    my ($dbh, $registry, $retire_missing) = @_;
1920
    my %seen;
1921
    for my $host (@{ $registry->{hosts} || [] }) {
1922
        my $fqdn = upsert_host_to_db($dbh, $host);
1923
        $seen{$fqdn} = 1 if $fqdn;
1924
    }
1925

            
1926
    return unless $retire_missing;
1927
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1928
    $sth->execute('retired');
1929
    while (my ($fqdn) = $sth->fetchrow_array) {
1930
        next if $seen{$fqdn};
1931
        retire_host_in_db($dbh, $fqdn);
1932
    }
1933
}
1934

            
1935
sub upsert_host_to_db {
1936
    my ($dbh, $host) = @_;
1937
    my $now = iso_now();
1938
    my $fqdn = canonical_host_fqdn($host);
1939
    return '' unless $fqdn;
1940
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1941
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
1942
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
1943
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1944
    my $notes = clean_scalar($host->{notes} || '');
1945

            
Bogdan Timofte authored 4 days ago
1946
    $dbh->do(
Bogdan Timofte authored 4 days ago
1947
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1948
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1949
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1950
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1951
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1952
        undef,
Bogdan Timofte authored 4 days ago
1953
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
1954
    );
1955

            
1956
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1957
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
1958
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
1959
    return $fqdn;
1960
}
1961

            
1962
sub sync_host_values {
1963
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1964
    my $now = iso_now();
1965
    my %active = map { $_ => 1 } @$values;
1966
    for my $value (@$values) {
1967
        $dbh->do(
1968
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1969
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1970
            undef,
1971
            $fqdn, $value, $now,
1972
        );
1973
    }
1974

            
1975
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1976
    $sth->execute($fqdn);
1977
    while (my ($value) = $sth->fetchrow_array) {
1978
        next if $active{$value};
1979
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1980
    }
1981
}
1982

            
Bogdan Timofte authored 4 days ago
1983
sub sync_host_aliases_and_vhosts {
1984
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
1985
    my $now = iso_now();
1986
    my (%aliases, %vhosts);
1987
    if (my $short = short_alias_for_fqdn($fqdn)) {
1988
        $aliases{$short} = 1;
1989
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1990
    }
Bogdan Timofte authored 4 days ago
1991
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
1992
        $name = normalize_dns_name($name);
1993
        next unless length $name;
1994
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
1995
        $aliases{$name} = 1;
1996
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1997
        if (my $short = short_alias_for_fqdn($name)) {
1998
            $aliases{$short} = 1;
1999
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2000
        }
2001
    }
2002
    for my $name (@$vhosts_in) {
2003
        $name = normalize_dns_name($name);
2004
        next unless length $name;
2005
        $vhosts{$name} = 1;
2006
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2007
        if (my $short = short_alias_for_fqdn($name)) {
2008
            $aliases{$short} = 1;
2009
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2010
        }
2011
    }
2012

            
2013
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2014
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2015
}
2016

            
2017
sub upsert_alias_to_db {
2018
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2019
    my ($existing_fqdn) = $dbh->selectrow_array(
2020
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2021
        undef,
2022
        $alias,
2023
    );
2024
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2025
        if ($kind eq 'derived-vhost') {
2026
            $dbh->do(
2027
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2028
                undef,
2029
                $now, $alias, $existing_fqdn,
2030
            );
2031
        } else {
2032
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2033
        }
2034
    }
Bogdan Timofte authored 4 days ago
2035
    $dbh->do(
2036
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2037
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2038
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2039
        undef,
2040
        $alias, $fqdn, $kind, $now,
2041
    );
2042
}
2043

            
2044
sub upsert_vhost_to_db {
2045
    my ($dbh, $fqdn, $vhost, $now) = @_;
2046
    my $service = vhost_service_name($vhost);
2047
    $dbh->do(
2048
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2049
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2050
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2051
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2052
        undef,
2053
        $vhost, $fqdn, $service, $now, $now,
2054
    );
2055
}
2056

            
2057
sub retire_missing_names {
2058
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2059
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2060
    $sth->execute($fqdn);
2061
    while (my ($name) = $sth->fetchrow_array) {
2062
        next if $active->{$name};
2063
        if ($table eq 'host_aliases') {
2064
            $dbh->do(
2065
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2066
                undef, $now, $fqdn, $name,
2067
            );
2068
        } else {
2069
            $dbh->do(
2070
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2071
                undef, $now, $fqdn, $name,
2072
            );
2073
        }
2074
    }
2075
}
2076

            
2077
sub retire_host_in_db {
2078
    my ($dbh, $fqdn) = @_;
2079
    my $now = iso_now();
2080
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2081
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2082
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2083
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2084
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2085
}
2086

            
Bogdan Timofte authored 4 days ago
2087
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2088
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2089
    my @names;
Bogdan Timofte authored 4 days ago
2090
    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");
2091
    $aliases->execute($fqdn);
2092
    while (my ($name) = $aliases->fetchrow_array) {
2093
        push @names, $name;
2094
    }
Bogdan Timofte authored 4 days ago
2095
    return unique_preserve(@names);
2096
}
2097

            
2098
sub active_vhosts_for_host {
2099
    my ($dbh, $fqdn) = @_;
2100
    my @names;
Bogdan Timofte authored 4 days ago
2101
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2102
    $vhosts->execute($fqdn);
2103
    while (my ($name) = $vhosts->fetchrow_array) {
2104
        push @names, $name;
2105
    }
2106
    return unique_preserve(@names);
2107
}
2108

            
2109
sub active_values_for_host {
2110
    my ($dbh, $table, $column, $fqdn) = @_;
2111
    my @values;
2112
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2113
    $sth->execute($fqdn);
2114
    while (my ($value) = $sth->fetchrow_array) {
2115
        push @values, $value;
2116
    }
2117
    return @values;
2118
}
2119

            
2120
sub load_work_orders_from_db {
2121
    my $dbh = dbh();
2122
    my $orders = { version => 1, work_orders => [] };
2123
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2124
    $sth->execute;
2125
    while (my $row = $sth->fetchrow_hashref) {
2126
        my $wo = {
2127
            id => $row->{id},
2128
            status => $row->{status},
2129
            title => $row->{title},
2130
            reason => $row->{reason},
2131
            created_at => $row->{created_at},
2132
            checklist => [],
2133
            actions => [],
2134
        };
2135
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2136
        $wo->{result} = $row->{result} if length($row->{result} || '');
2137

            
2138
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2139
        $items->execute($row->{id});
2140
        while (my $item = $items->fetchrow_hashref) {
2141
            my %copy = (
2142
                id => $item->{item_id},
2143
                text => $item->{text},
2144
                status => $item->{status},
2145
            );
2146
            for my $key (qw(owner notes updated_at)) {
2147
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2148
            }
2149
            push @{ $wo->{checklist} }, \%copy;
2150
        }
2151

            
2152
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2153
        $actions->execute($row->{id});
2154
        while (my $action = $actions->fetchrow_hashref) {
2155
            my %copy = ( type => $action->{type} );
2156
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2157
            $copy{name} = $action->{name} if length($action->{name} || '');
2158
            push @{ $wo->{actions} }, \%copy;
2159
        }
2160

            
2161
        push @{ $orders->{work_orders} }, $wo;
2162
    }
2163
    return $orders;
2164
}
2165

            
2166
sub save_work_orders_to_db {
2167
    my ($orders) = @_;
2168
    my $dbh = dbh();
2169
    with_transaction($dbh, sub {
2170
        import_work_orders_to_db($dbh, $orders);
2171
    });
2172
}
2173

            
2174
sub import_work_orders_to_db {
2175
    my ($dbh, $orders) = @_;
2176
    my $now = iso_now();
2177
    my %seen;
2178
    for my $wo (@{ $orders->{work_orders} || [] }) {
2179
        my $id = clean_scalar($wo->{id} || '');
2180
        next unless $id;
2181
        $seen{$id} = 1;
2182
        $dbh->do(
2183
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2184
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2185
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2186
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2187
            undef,
2188
            $id,
2189
            clean_scalar($wo->{status} || 'pending'),
2190
            clean_scalar($wo->{title} || ''),
2191
            clean_scalar($wo->{reason} || ''),
2192
            clean_scalar($wo->{created_at} || $now),
2193
            clean_scalar($wo->{confirmed_at} || ''),
2194
            clean_scalar($wo->{result} || ''),
2195
            $now,
2196
        );
2197
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2198
        for my $item (@{ $wo->{checklist} || [] }) {
2199
            $dbh->do(
2200
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2201
                undef,
2202
                $id,
2203
                clean_scalar($item->{id} || ''),
2204
                clean_scalar($item->{text} || ''),
2205
                clean_scalar($item->{status} || 'pending'),
2206
                clean_scalar($item->{owner} || ''),
2207
                clean_scalar($item->{notes} || ''),
2208
                clean_scalar($item->{updated_at} || ''),
2209
            );
2210
        }
2211
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2212
        my $position = 0;
2213
        for my $action (@{ $wo->{actions} || [] }) {
2214
            my $legacy_id = clean_id($action->{host_id} || '');
2215
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2216
            $dbh->do(
2217
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2218
                undef,
2219
                $id,
2220
                $position++,
2221
                clean_scalar($action->{type} || ''),
2222
                $host_fqdn || undef,
2223
                $legacy_id,
2224
                normalize_dns_name($action->{name} || ''),
2225
                '',
2226
            );
2227
        }
2228
    }
2229
}
2230

            
2231
sub seed_default_workers {
2232
    my ($dbh) = @_;
2233
    my $now = iso_now();
2234
    my @workers = (
2235
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2236
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2237
    );
2238
    for my $worker (@workers) {
2239
        $dbh->do(
2240
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2241
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2242
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2243
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2244
            undef,
2245
            @$worker,
2246
            $now,
2247
            $now,
2248
        );
2249
    }
2250
}
2251

            
2252
sub seed_mdns_observations_from_yaml {
2253
    my ($dbh) = @_;
2254
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2255
    my $path = "$project_dir/var/mdns-observations.yaml";
2256
    return unless -f $path;
2257
    my $db = parse_mdns_observations_yaml(read_file($path));
2258
    with_transaction($dbh, sub {
2259
        for my $observation (@{ $db->{observations} || [] }) {
2260
            $dbh->do(
2261
                '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) '
2262
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2263
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2264
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2265
                undef,
2266
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2267
                clean_scalar($observation->{name} || ''),
2268
                clean_scalar($observation->{ip} || ''),
2269
                int($observation->{ttl} || 0),
2270
                clean_scalar($observation->{first_seen} || iso_now()),
2271
                clean_scalar($observation->{last_seen} || iso_now()),
2272
                int($observation->{seen_count} || 1),
2273
                clean_scalar($observation->{last_peer} || ''),
2274
            );
2275
        }
2276
    });
2277
}
2278

            
2279
sub parse_mdns_observations_yaml {
2280
    my ($text) = @_;
2281
    my %db = ( observations => [] );
2282
    my ($section, $current);
2283
    for my $line (split /\n/, $text || '') {
2284
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2285
        if ($line =~ /^observations:\s*$/) {
2286
            $section = 'observations';
2287
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2288
            $current = { key => yaml_unquote($1) };
2289
            push @{ $db{observations} }, $current;
2290
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2291
            $current->{$1} = yaml_unquote($2);
2292
        }
2293
    }
2294
    return \%db;
2295
}
2296

            
2297
sub set_schema_meta {
2298
    my ($dbh, $key, $value) = @_;
2299
    $dbh->do(
2300
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2301
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2302
        undef,
2303
        $key,
2304
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2305
        iso_now(),
2306
    );
2307
}
2308

            
Bogdan Timofte authored 4 days ago
2309
sub fqdn_for_legacy_id {
2310
    my ($dbh, $legacy_id) = @_;
2311
    return '' unless length($legacy_id || '');
2312
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2313
    return $fqdn || '';
2314
}
2315

            
2316
sub canonical_host_fqdn {
2317
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2318
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2319
    return $fqdn if length $fqdn;
2320
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2321
    for my $name (@names) {
2322
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2323
    }
2324
    for my $name (@names) {
2325
        return $name if $name =~ /\./ && !name_is_vhost($name);
2326
    }
2327
    my $id = clean_id($host->{id} || '');
2328
    return $id ? "$id.madagascar.xdev.ro" : '';
2329
}
2330

            
2331
sub legacy_id_from_fqdn {
2332
    my ($fqdn) = @_;
2333
    $fqdn = normalize_dns_name($fqdn);
2334
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2335
    $fqdn =~ s/\..*\z//;
2336
    return clean_id($fqdn);
2337
}
2338

            
2339
sub normalize_dns_name {
2340
    my ($name) = @_;
2341
    $name = lc clean_scalar($name || '');
2342
    $name =~ s/\.\z//;
2343
    return $name;
2344
}
2345

            
2346
sub name_is_vhost {
2347
    my ($name) = @_;
2348
    $name = normalize_dns_name($name);
2349
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2350
}
2351

            
2352
sub vhost_service_name {
2353
    my ($name) = @_;
2354
    $name = normalize_dns_name($name);
2355
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2356
    return '';
2357
}
2358

            
2359
sub short_alias_for_fqdn {
2360
    my ($name) = @_;
2361
    $name = normalize_dns_name($name);
2362
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2363
    return '';
2364
}
2365

            
Bogdan Timofte authored 4 days ago
2366
sub normalize_registry_policy {
2367
    my ($registry) = @_;
2368
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2369
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2370
    $registry->{policy}{runtime_database} = $opt{db};
2371
}
2372

            
2373
sub default_hosts_yaml {
2374
    return <<'YAML';
2375
version: 1
2376
updated_at: ""
2377
policy:
Bogdan Timofte authored 4 days ago
2378
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2379
hosts:
2380
YAML
2381
}
2382

            
2383
sub default_work_orders_yaml {
2384
    return <<'YAML';
2385
version: 1
2386
work_orders:
2387
YAML
2388
}
2389

            
2390
sub ensure_parent_dir {
2391
    my ($path) = @_;
2392
    my $dir = dirname($path);
2393
    make_path($dir) unless -d $dir;
2394
}
2395

            
Xdev Host Manager authored a week ago
2396
sub url_decode {
2397
    my ($value) = @_;
2398
    $value = '' unless defined $value;
2399
    $value =~ tr/+/ /;
2400
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2401
    return $value;
2402
}
2403

            
2404
sub random_hex {
2405
    my ($bytes) = @_;
2406
    if (open my $fh, '<:raw', '/dev/urandom') {
2407
        read($fh, my $raw, $bytes);
2408
        close $fh;
2409
        return unpack('H*', $raw);
2410
    }
2411
    return sha256_hex(rand() . time() . $$);
2412
}
2413

            
2414
sub iso_now {
2415
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2416
}
2417

            
Bogdan Timofte authored 6 days ago
2418
sub build_info {
2419
    my %info = (
2420
        revision => '',
2421
        branch => '',
2422
        built_at => '',
2423
        deployed_at => '',
2424
        dirty => '',
2425
    );
2426

            
2427
    if ($ENV{HOST_MANAGER_BUILD}) {
2428
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2429
        return \%info;
2430
    }
2431

            
2432
    my $build_file = "$project_dir/BUILD";
2433
    if (-f $build_file) {
2434
        for my $line (split /\n/, read_file($build_file)) {
2435
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2436
            $info{$1} = clean_scalar($2);
2437
        }
2438
        return \%info if $info{revision} || $info{built_at};
2439
    }
2440

            
2441
    my $revision = git_value('rev-parse --short=12 HEAD');
2442
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2443
    $info{revision} = $revision if $revision;
2444
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2445
    return \%info;
2446
}
2447

            
2448
sub git_value {
2449
    my ($args) = @_;
2450
    return '' unless -d "$project_dir/.git";
2451
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2452
    my $value = <$fh> || '';
2453
    close $fh;
2454
    chomp $value;
2455
    return clean_scalar($value);
2456
}
2457

            
2458
sub build_label {
2459
    my $info = build_info();
2460
    my $revision = $info->{revision} || 'unknown';
2461
    my $branch = $info->{branch} || '';
2462
    $branch = '' if $branch eq 'HEAD';
2463
    my $label = $branch ? "$branch $revision" : $revision;
2464
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2465
    return $label;
2466
}
2467

            
2468
sub build_title {
2469
    my $info = build_info();
2470
    my $label = build_label();
2471
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2472
    return $stamp ? "$label deployed $stamp" : $label;
2473
}
2474

            
Bogdan Timofte authored 4 days ago
2475
sub build_revision {
2476
    my $info = build_info();
2477
    return $info->{revision} || 'unknown';
2478
}
2479

            
2480
sub build_details {
2481
    my $info = build_info();
2482
    my %details = (
2483
        app => 'Madagascar Local Authority',
2484
        revision => $info->{revision} || 'unknown',
2485
        branch => $info->{branch} || '',
2486
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2487
        built_at => $info->{built_at} || '',
2488
        deployed_at => $info->{deployed_at} || '',
2489
        label => build_label(),
2490
        title => build_title(),
2491
    );
2492
    return json_encode(\%details);
2493
}
2494

            
Bogdan Timofte authored 6 days ago
2495
sub html_escape {
2496
    my ($value) = @_;
2497
    $value = '' unless defined $value;
2498
    $value =~ s/&/&amp;/g;
2499
    $value =~ s/</&lt;/g;
2500
    $value =~ s/>/&gt;/g;
2501
    $value =~ s/"/&quot;/g;
2502
    $value =~ s/'/&#039;/g;
2503
    return $value;
2504
}
2505

            
Xdev Host Manager authored a week ago
2506
sub app_html {
Bogdan Timofte authored 4 days ago
2507
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2508
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2509
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2510
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2511
<!doctype html>
2512
<html lang="ro">
2513
<head>
2514
  <meta charset="utf-8">
2515
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2516
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2517
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2518
  <style>
2519
    :root {
2520
      color-scheme: light;
2521
      --ink: #152033;
2522
      --muted: #647084;
2523
      --line: #d8dee8;
2524
      --soft: #f4f6f9;
2525
      --panel: #ffffff;
2526
      --accent: #1267d8;
2527
      --bad: #b42318;
2528
      --warn: #946200;
2529
      --ok: #137333;
2530
    }
2531
    * { box-sizing: border-box; }
2532
    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
2533

            
2534
    /* ── Login screen ── */
2535
    #login-screen {
2536
      display: flex;
Xdev Host Manager authored a week ago
2537
      align-items: flex-start;
Xdev Host Manager authored a week ago
2538
      justify-content: center;
2539
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2540
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2541
      background: #13182a;
Xdev Host Manager authored a week ago
2542
      overflow: auto;
Xdev Host Manager authored a week ago
2543
    }
2544
    .login-card {
Xdev Host Manager authored a week ago
2545
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2546
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2547
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2548
      background: #fff;
2549
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2550
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2551
         below the first box, sits inside the card instead of spilling past it. */
2552
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2553
      width: 100%;
Xdev Host Manager authored a week ago
2554
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2555
      min-height: 360px;
Xdev Host Manager authored a week ago
2556
      display: grid;
Xdev Host Manager authored a week ago
2557
      align-content: start;
2558
      justify-items: center;
2559
      gap: 28px;
Xdev Host Manager authored a week ago
2560
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2561
    }
Xdev Host Manager authored a week ago
2562
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2563
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2564
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2565
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2566
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2567
    }
Xdev Host Manager authored a week ago
2568
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2569
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2570
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2571
    .login-card form {
2572
      display: grid;
2573
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2574
      justify-self: center;
Bogdan Timofte authored a week ago
2575
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2576
    }
Xdev Host Manager authored a week ago
2577
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2578
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2579
       giving the password manager a username anchor and an aggregated OTP target
2580
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2581
    .pm-helper-fields {
2582
      position: absolute;
2583
      left: -10000px;
2584
      top: auto;
2585
      width: 1px;
2586
      height: 1px;
2587
      overflow: hidden;
2588
      opacity: 0.01;
2589
    }
2590
    .pm-helper-fields input {
2591
      width: 1px;
2592
      height: 1px;
2593
      padding: 0;
2594
      border: 0;
2595
    }
Bogdan Timofte authored 4 days ago
2596
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2597
       hint was what made Safari mark the whole group and re-present its OTP
2598
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2599
    .otp-row {
2600
      display: flex;
2601
      gap: var(--otp-gap);
2602
      justify-content: center;
2603
    }
Bogdan Timofte authored 4 days ago
2604
    .otp-row input {
Xdev Host Manager authored a week ago
2605
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 4 days ago
2606
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2607
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2608
      transition: border-color .15s, background .15s;
2609
    }
Bogdan Timofte authored 4 days ago
2610
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2611
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2612
    #login-error {
2613
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2614
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2615
    }
2616
    @media (max-width: 760px) {
2617
      .login-card {
Xdev Host Manager authored a week ago
2618
        max-width: 520px;
Xdev Host Manager authored a week ago
2619
        min-height: 0;
Bogdan Timofte authored 4 days ago
2620
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2621
        gap: 26px;
2622
      }
2623
      .login-card .brand h1 { font-size: 24px; }
2624
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2625
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2626
    }
Xdev Host Manager authored a week ago
2627
    @media (max-width: 430px) {
2628
      #login-screen { padding: 24px 16px 120px; }
2629
      .login-card {
2630
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2631
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2632
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2633
      }
Bogdan Timofte authored 4 days ago
2634
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2635
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2636
    }
2637
    @media (max-height: 720px) {
2638
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2639
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2640
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2641
    }
Xdev Host Manager authored a week ago
2642

            
2643
    /* ── App shell (hidden until authenticated) ── */
2644
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2645
    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
2646
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2647
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2648
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2649
    nav a:hover { color: var(--ink); background: var(--soft); }
2650
    nav a.active { color: var(--accent); background: #e8f0fe; }
2651
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2652
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2653
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2654
    .page { display: grid; gap: 16px; }
2655
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2656
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2657
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2658
    .panel { overflow: hidden; }
2659
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2660
    .panel-head h2 { margin: 0; font-size: 14px; }
2661
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2662
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2663
    button, input, select, textarea { font: inherit; }
2664
    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; }
2665
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2666
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2667
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2668
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2669
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2670
    textarea { min-height: 74px; resize: vertical; }
2671
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2672
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2673
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2674
    tr:hover td { background: #f8fafc; }
2675
    .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; }
2676
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2677
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2678
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 4 days ago
2679
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
2680
    .pill.canonical { font-weight: 700; }
2681
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
2682
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2683
    .span2 { grid-column: 1 / -1; }
2684
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2685
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2686
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2687
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2688
    .ca-fingerprint { overflow-wrap: anywhere; }
2689
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2690
    .build-control {
Bogdan Timofte authored 6 days ago
2691
      position: fixed;
2692
      right: 10px;
2693
      bottom: 8px;
2694
      z-index: 5;
Bogdan Timofte authored 4 days ago
2695
      display: inline-flex;
2696
      align-items: center;
2697
      gap: 4px;
2698
    }
2699
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2700
      color: rgba(255,255,255,.46);
2701
      background: rgba(19,24,42,.28);
2702
      border: 1px solid rgba(255,255,255,.08);
2703
      border-radius: 4px;
2704
      font-size: 10px;
2705
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2706
    }
2707
    .build-badge {
2708
      padding: 2px 5px;
Bogdan Timofte authored 4 days ago
2709
      cursor: text;
2710
      user-select: text;
Bogdan Timofte authored 6 days ago
2711
    }
Bogdan Timofte authored 4 days ago
2712
    .build-copy {
2713
      min-height: 0;
2714
      padding: 2px 5px;
2715
      cursor: pointer;
2716
    }
2717
    .build-copy:hover {
2718
      color: rgba(255,255,255,.72);
2719
      border-color: rgba(255,255,255,.24);
2720
    }
2721
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2722
      color: rgba(100,112,132,.58);
2723
      background: rgba(255,255,255,.72);
2724
      border-color: rgba(216,222,232,.72);
2725
    }
Bogdan Timofte authored 4 days ago
2726
    body.is-app .build-copy:hover {
2727
      color: rgba(21,32,51,.78);
2728
      border-color: rgba(100,112,132,.42);
2729
    }
Xdev Host Manager authored a week ago
2730
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2731
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2732
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2733
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2734
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2735
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2736
    .work-order-actions { gap: 4px; }
2737
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2738
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2739
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2740
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
2741
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
2742
    .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
2743
    .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
2744
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
2745
    .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
2746
    .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; }
2747
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
2748
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
2749
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
2750
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
2751
    .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; }
2752
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
2753
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
2754
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2755
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
2756
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2757
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2758
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 4 days ago
2759
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
2760
    #page-vhosts .host-tools { flex-wrap: wrap; }
2761
    #page-vhosts .host-tools input { max-width: 280px; }
2762
    #page-vhosts .stats { justify-content: flex-end; }
2763
    .vhost-host { display: grid; gap: 2px; }
Bogdan Timofte authored 4 days ago
2764
    .vhost-host-actions { display: flex; align-items: center; gap: 6px; }
2765
    .vhost-host-actions button { min-height: 28px; padding: 4px 8px; font-size: 12px; }
Bogdan Timofte authored 4 days ago
2766
    .vhost-host .mono { font-size: 11px; line-height: 1.2; color: var(--muted); }
2767
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
2768
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
2769
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 5 days ago
2770
    .modal-backdrop {
2771
      position: fixed;
2772
      inset: 0;
2773
      z-index: 10;
2774
      display: grid;
2775
      align-items: start;
2776
      justify-items: center;
2777
      padding: 72px 16px 24px;
2778
      background: rgba(21,32,51,.48);
2779
      overflow: auto;
2780
    }
2781
    .modal-backdrop[hidden] { display: none; }
2782
    .modal {
2783
      width: min(840px, 100%);
2784
      max-height: calc(100dvh - 96px);
2785
      overflow: auto;
2786
      background: var(--panel);
2787
      border: 1px solid var(--line);
2788
      border-radius: 8px;
2789
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2790
    }
2791
    .modal-head {
2792
      position: sticky;
2793
      top: 0;
2794
      z-index: 1;
2795
      display: flex;
2796
      align-items: center;
2797
      justify-content: space-between;
2798
      gap: 12px;
2799
      padding: 12px 14px;
2800
      border-bottom: 1px solid var(--line);
2801
      background: #fafbfc;
2802
    }
2803
    .modal-head h2 { margin: 0; font-size: 14px; }
2804
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2805
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2806
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2807
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2808
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2809
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2810
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2811
      #message { max-width: 100%; }
2812
      .panel-head { align-items: stretch; flex-direction: column; }
2813
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2814
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2815
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2816
      .modal-backdrop { padding-top: 16px; }
2817
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2818
      .grid { grid-template-columns: 1fr; }
2819
      table { min-width: 760px; }
2820
      .table-wrap { overflow-x: auto; }
2821
    }
2822
  </style>
2823
</head>
Bogdan Timofte authored 6 days ago
2824
<body class="is-login">
Xdev Host Manager authored a week ago
2825

            
Xdev Host Manager authored a week ago
2826
  <!-- ── Login screen ── -->
2827
  <div id="login-screen">
2828
    <div class="login-card">
2829
      <div class="brand">
2830
        <div class="icon">
Xdev Host Manager authored a week ago
2831
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2832
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2833
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2834
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2835
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2836
            <path d="M26 20h8M26 32h8M26 44h8"/>
2837
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2838
          </svg>
2839
        </div>
Xdev Host Manager authored a week ago
2840
        <h1>Madagascar Local Authority</h1>
2841
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2842
      </div>
Bogdan Timofte authored 4 days ago
2843
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2844
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2845
        <div class="pm-helper-fields" aria-hidden="true">
2846
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2847
          <input type="hidden" id="otp-hidden" name="otp">
2848
        </div>
Xdev Host Manager authored a week ago
2849
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2850
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2851
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2852
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2853
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2854
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2855
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2856
        </div>
2857
      </form>
2858
    </div>
2859
  </div>
2860

            
2861
  <!-- ── App (shown after login) ── -->
2862
  <div id="app">
2863
    <header>
Xdev Host Manager authored a week ago
2864
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2865
      <nav aria-label="Sections">
2866
        <a href="/overview" data-page-link="overview">Overview</a>
2867
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
2868
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
2869
        <a href="/dns" data-page-link="dns">DNS</a>
2870
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2871
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2872
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2873
      </nav>
Xdev Host Manager authored a week ago
2874
      <div class="header-right">
2875
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2876
        <span id="message" class="muted"></span>
2877
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2878
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2879
      </div>
Xdev Host Manager authored a week ago
2880
    </header>
2881
    <main>
Bogdan Timofte authored 5 days ago
2882
      <section class="page" id="page-overview" data-page="overview">
2883
        <section class="panel">
2884
          <div class="panel-head">
2885
            <h2>Overview</h2>
2886
            <div class="stats" id="stats"></div>
2887
          </div>
2888
          <div class="problems" id="problems"></div>
2889
        </section>
Xdev Host Manager authored a week ago
2890
      </section>
2891

            
Bogdan Timofte authored 5 days ago
2892
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2893
        <section class="panel">
2894
          <div class="panel-head">
2895
            <h2>Hosts</h2>
2896
            <div class="host-tools">
2897
              <input id="filter" placeholder="filter">
2898
              <button type="button" id="new-host">New host</button>
2899
            </div>
2900
          </div>
2901
          <div class="table-wrap">
2902
            <table>
2903
              <thead>
2904
                <tr>
2905
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
2906
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
2907
                  <th>Names</th>
2908
                  <th style="width: 150px">Roles</th>
2909
                  <th style="width: 110px">Monitoring</th>
2910
                  <th style="width: 90px">Status</th>
2911
                </tr>
2912
              </thead>
2913
              <tbody id="hosts"></tbody>
2914
            </table>
2915
          </div>
2916
        </section>
Xdev Host Manager authored a week ago
2917
      </section>
Xdev Host Manager authored a week ago
2918

            
Bogdan Timofte authored 4 days ago
2919
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
2920
        <section class="panel">
2921
          <div class="panel-head">
2922
            <h2>Vhosts</h2>
2923
            <div class="host-tools">
2924
              <input id="vhost-filter" placeholder="filter">
2925
              <div class="stats" id="vhost-stats"></div>
2926
            </div>
2927
          </div>
2928
          <div class="table-wrap">
2929
            <table>
2930
              <thead>
2931
                <tr>
2932
                  <th>Vhost</th>
2933
                  <th style="width: 190px">Host</th>
2934
                  <th style="width: 140px">IP</th>
2935
                  <th style="width: 180px">Derived aliases</th>
2936
                  <th style="width: 120px">Monitoring</th>
2937
                  <th style="width: 90px">Status</th>
2938
                </tr>
2939
              </thead>
2940
              <tbody id="vhosts"></tbody>
2941
            </table>
2942
          </div>
2943
        </section>
2944
      </section>
2945

            
Bogdan Timofte authored 5 days ago
2946
      <section class="page" id="page-dns" data-page="dns" hidden>
2947
        <section class="toolbar">
2948
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2949
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2950
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2951
          <button id="write-tsv">Write local-hosts.tsv</button>
2952
        </section>
Xdev Host Manager authored a week ago
2953
      </section>
2954

            
Bogdan Timofte authored 5 days ago
2955
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2956
        <section class="panel">
2957
          <div class="panel-head">
2958
            <h2>Work Orders</h2>
2959
            <div class="stats" id="wo-stats"></div>
2960
          </div>
2961
          <div class="problems" id="work-orders"></div>
2962
        </section>
Xdev Host Manager authored a week ago
2963
      </section>
2964

            
Bogdan Timofte authored 5 days ago
2965
      <section class="page" id="page-ca" data-page="ca" hidden>
2966
        <section class="panel">
2967
          <div class="panel-head">
2968
            <h2>Local Certificate Authority</h2>
2969
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2970
          </div>
2971
          <div class="problems" id="ca-status"></div>
2972
        </section>
2973
        <section class="panel">
2974
          <div class="panel-head">
2975
            <h2>Issued Certificates</h2>
2976
            <div class="stats" id="ca-certs-summary"></div>
2977
          </div>
2978
          <div class="table-wrap">
2979
            <table>
2980
              <thead>
2981
                <tr>
2982
                  <th style="width: 150px">Name</th>
2983
                  <th>DNS names</th>
2984
                  <th style="width: 210px">Validity</th>
2985
                  <th style="width: 180px">Serial</th>
2986
                  <th>Fingerprint</th>
2987
                  <th style="width: 110px">Download</th>
2988
                </tr>
2989
              </thead>
2990
              <tbody id="ca-certs"></tbody>
2991
            </table>
2992
          </div>
2993
        </section>
Xdev Host Manager authored a week ago
2994
      </section>
Bogdan Timofte authored 4 days ago
2995

            
2996
      <section class="page" id="page-debug" data-page="debug" hidden>
2997
        <section class="panel">
2998
          <div class="panel-head">
2999
            <h2>Database</h2>
3000
            <div class="stats" id="debug-db-stats"></div>
3001
          </div>
3002
          <div class="toolbar">
3003
            <div class="debug-controls">
3004
              <button type="button" id="debug-db-refresh">Refresh</button>
3005
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3006
            </div>
3007
          </div>
Bogdan Timofte authored 4 days ago
3008
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3009
        </section>
3010
        <section class="debug-section">
3011
          <section class="panel">
3012
            <div class="panel-head">
3013
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3014
              <div class="debug-table-head-actions">
3015
                <div class="stats" id="debug-table-stats"></div>
3016
                <div class="debug-table-exports">
3017
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3018
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3019
                </div>
3020
              </div>
Bogdan Timofte authored 4 days ago
3021
            </div>
3022
            <div class="table-wrap" id="debug-table-rows"></div>
3023
          </section>
3024
          <section class="panel">
3025
            <div class="panel-head">
3026
              <h2>Columns</h2>
3027
            </div>
3028
            <div class="table-wrap" id="debug-table-columns"></div>
3029
          </section>
3030
          <section class="panel">
3031
            <div class="panel-head">
3032
              <h2>Indexes</h2>
3033
            </div>
3034
            <div class="table-wrap" id="debug-table-indexes"></div>
3035
          </section>
3036
          <section class="panel">
3037
            <div class="panel-head">
3038
              <h2>Foreign Keys</h2>
3039
            </div>
3040
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3041
          </section>
3042
        </section>
3043
      </section>
Bogdan Timofte authored 5 days ago
3044
    </main>
Xdev Host Manager authored a week ago
3045

            
Bogdan Timofte authored 5 days ago
3046
    <div id="host-modal" class="modal-backdrop" hidden>
3047
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
3048
        <div class="modal-head">
3049
          <h2 id="host-modal-title">Edit host</h2>
3050
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
3051
        </div>
3052
        <form id="host-form" class="grid">
3053
          <label>ID<input name="id" required></label>
Bogdan Timofte authored 4 days ago
3054
          <label>FQDN<input name="fqdn" required></label>
Xdev Host Manager authored a week ago
3055
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
Bogdan Timofte authored 4 days ago
3056
          <label>IP<input name="ip" required></label>
3057
          <label class="span2">Aliases<textarea name="aliases"></textarea></label>
3058
          <label class="span2">Vhosts<textarea name="vhosts"></textarea></label>
Xdev Host Manager authored a week ago
3059
          <label>Roles<input name="roles"></label>
3060
          <label>Sources<input name="sources"></label>
3061
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3062
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
3063
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
3064
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
3065
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
3066
            <button class="danger" type="button" id="delete-host">Delete host</button>
3067
          </div>
3068
        </form>
3069
      </section>
Bogdan Timofte authored 5 days ago
3070
    </div>
Xdev Host Manager authored a week ago
3071
  </div>
3072

            
Bogdan Timofte authored 4 days ago
3073
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3074
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3075
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3076
  </div>
Bogdan Timofte authored 6 days ago
3077

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

            
3082
    const $ = (id) => document.getElementById(id);
3083
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
3084
    const PAGE_PATHS = {
3085
      '/': 'overview',
3086
      '/overview': 'overview',
3087
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3088
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3089
      '/dns': 'dns',
3090
      '/work-orders': 'work-orders',
3091
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3092
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3093
    };
Xdev Host Manager authored a week ago
3094

            
Bogdan Timofte authored 4 days ago
3095
    function isAuthLost(error) {
3096
      return !!(error && error.authLost);
3097
    }
3098

            
3099
    function authLostError(message) {
3100
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3101
      error.authLost = true;
3102
      return error;
3103
    }
3104

            
3105
    function handleAuthLost(message) {
3106
      state.authenticated = false;
3107
      msg('');
3108
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3109
    }
3110

            
Bogdan Timofte authored 4 days ago
3111
    async function ensureAuthenticated(message) {
3112
      if (!state.authenticated) {
3113
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3114
        return false;
3115
      }
3116
      const session = await api('/api/session');
3117
      state.authenticated = session.authenticated;
3118
      if (!state.authenticated) {
3119
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3120
        return false;
3121
      }
3122
      return true;
3123
    }
3124

            
Xdev Host Manager authored a week ago
3125
    async function api(path, options = {}) {
3126
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3127
      let body = {};
3128
      try {
3129
        body = await res.json();
3130
      } catch (_) {
3131
        body = {};
3132
      }
3133
      const errorCode = body.error || '';
3134
      if (!res.ok) {
3135
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3136
          const error = authLostError();
3137
          handleAuthLost(error.message);
3138
          throw error;
3139
        }
3140
        throw new Error(errorCode || res.statusText);
3141
      }
Xdev Host Manager authored a week ago
3142
      return body;
3143
    }
3144

            
Bogdan Timofte authored 5 days ago
3145
    function currentPage() {
3146
      return PAGE_PATHS[window.location.pathname] || 'overview';
3147
    }
3148

            
3149
    function showPage(page, push = false) {
3150
      const target = page || 'overview';
3151
      document.querySelectorAll('[data-page]').forEach(section => {
3152
        section.hidden = section.dataset.page !== target;
3153
      });
3154
      document.querySelectorAll('[data-page-link]').forEach(link => {
3155
        link.classList.toggle('active', link.dataset.pageLink === target);
3156
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3157
      });
3158
      if (push) {
3159
        const href = target === 'overview' ? '/overview' : '/' + target;
3160
        history.pushState({ page: target }, '', href);
3161
      }
Bogdan Timofte authored 4 days ago
3162
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3163
        renderDebugDatabase().catch(e => {
3164
          if (!isAuthLost(e)) msg(e.message);
3165
        });
Bogdan Timofte authored 4 days ago
3166
      }
Bogdan Timofte authored 5 days ago
3167
    }
3168

            
Xdev Host Manager authored a week ago
3169
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3170
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3171
      document.body.classList.remove('is-app');
3172
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3173
      $('app').style.display = 'none';
3174
      $('login-screen').style.display = 'flex';
3175
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3176
      clearOtp();
Xdev Host Manager authored a week ago
3177
    }
3178

            
3179
    function showApp() {
Bogdan Timofte authored 6 days ago
3180
      document.body.classList.remove('is-login');
3181
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3182
      $('login-screen').style.display = 'none';
3183
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3184
      showPage(currentPage());
Xdev Host Manager authored a week ago
3185
    }
3186

            
Xdev Host Manager authored a week ago
3187
    async function refresh() {
3188
      const session = await api('/api/session');
3189
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3190
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3191
      showApp();
Xdev Host Manager authored a week ago
3192
      const data = await api('/api/hosts');
3193
      state.hosts = data.hosts || [];
3194
      state.problems = data.problems || [];
3195
      render(data);
Xdev Host Manager authored a week ago
3196
      await renderCa();
Xdev Host Manager authored a week ago
3197
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3198
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3199
    }
3200

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

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

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

            
3214
      renderHosts();
Bogdan Timofte authored 4 days ago
3215
      renderVhosts();
Xdev Host Manager authored a week ago
3216
    }
3217

            
Xdev Host Manager authored a week ago
3218
    async function renderCa() {
3219
      try {
3220
        const status = await api('/api/ca/status');
3221
        if (!status.initialized) {
3222
          $('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
3223
          $('ca-certs-summary').innerHTML = '';
3224
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3225
          return;
3226
        }
3227
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
3228
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3229
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3230
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3231
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3232
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3233
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3234
            <div>
3235
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3236
              <span>${certs.length} issued certificate(s)</span>
3237
            </div>
Xdev Host Manager authored a week ago
3238
          </div>`;
Bogdan Timofte authored 5 days ago
3239
        $('ca-certs-summary').innerHTML = [
3240
          ['issued', certs.length],
3241
          ['expiring', certs.filter(cert => {
3242
            const days = daysUntil(cert.not_after);
3243
            return days !== null && days >= 0 && days <= 30;
3244
          }).length],
3245
          ['expired', certs.filter(cert => {
3246
            const days = daysUntil(cert.not_after);
3247
            return days !== null && days < 0;
3248
          }).length],
3249
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3250
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3251
          const days = daysUntil(cert.not_after);
3252
          const dnsNames = cert.dns_names || [];
3253
          const dnsHtml = dnsNames.length
3254
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3255
            : '<span class="muted">No DNS SANs reported.</span>';
3256
          return `<tr>
3257
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3258
            <td>${dnsHtml}</td>
3259
            <td>
3260
              <div class="ca-detail">
3261
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3262
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3263
              </div>
3264
            </td>
3265
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3266
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
3267
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
3268
          </tr>`;
3269
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3270
      } catch (e) {
Bogdan Timofte authored 4 days ago
3271
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3272
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3273
        $('ca-certs-summary').innerHTML = '';
3274
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3275
      }
3276
    }
3277

            
Bogdan Timofte authored 5 days ago
3278
    function daysUntil(dateText) {
3279
      const time = Date.parse(dateText || '');
3280
      if (!Number.isFinite(time)) return null;
3281
      return Math.ceil((time - Date.now()) / 86400000);
3282
    }
3283

            
3284
    function certStatusClass(days) {
3285
      if (days === null) return '';
3286
      if (days < 0) return 'bad';
3287
      if (days <= 30) return 'warn';
3288
      return 'ok';
3289
    }
3290

            
3291
    function certStatusLabel(days) {
3292
      if (days === null) return 'validity unknown';
3293
      if (days < 0) return 'expired';
3294
      if (days === 0) return 'expires today';
3295
      return `${days}d remaining`;
3296
    }
3297

            
Xdev Host Manager authored a week ago
3298
    async function renderWorkOrders() {
3299
      try {
3300
        const data = await api('/api/work-orders');
3301
        state.workOrders = data.work_orders || [];
3302
        $('wo-stats').innerHTML = [
3303
          ['pending', data.counts.pending],
3304
          ['total', data.counts.work_orders],
3305
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3306

            
3307
        if (!state.workOrders.length) {
3308
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3309
          return;
3310
        }
3311

            
3312
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3313
          const checklist = wo.checklist || [];
3314
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3315
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3316
          const checklistHtml = checklist.map(item => {
3317
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3318
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3319
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3320
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3321
            </label>`;
3322
          }).join('');
Xdev Host Manager authored a week ago
3323
          const actions = (wo.actions || []).map(a => {
3324
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3325
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3326
          }).join('');
3327
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3328
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3329
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3330
            : '';
Bogdan Timofte authored 6 days ago
3331
          return `<div class="problem work-order-card">
3332
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3333
              <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
3334
              ${button}
3335
            </div>
Bogdan Timofte authored 6 days ago
3336
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3337
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3338
            <div class="work-order-checklist">${checklistHtml}</div>
3339
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3340
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3341
          </div>`;
3342
        }).join('');
Xdev Host Manager authored a week ago
3343
        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
3344
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3345
      } catch (e) {
Bogdan Timofte authored 4 days ago
3346
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3347
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3348
      }
3349
    }
3350

            
Bogdan Timofte authored 4 days ago
3351
    async function renderDebugDatabase() {
3352
      if (!state.authenticated) return;
3353
      const data = await api('/api/debug/database/tables');
3354
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3355
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3356
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3357
      $('debug-db-stats').innerHTML = [
3358
        ['tables', data.counts ? data.counts.tables : tables.length],
3359
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3360
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3361
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3362
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3363
      if (selected) {
3364
        await renderDebugTable(selected);
3365
      } else {
3366
        clearDebugTable();
3367
      }
3368
    }
3369

            
Bogdan Timofte authored 4 days ago
3370
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3371
      $('debug-db-tables').innerHTML = tables.length
3372
        ? tables.map(table => {
3373
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3374
            const ref = debugTableReference(database, table.name);
3375
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3376
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3377
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3378
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3379
              </button>
Bogdan Timofte authored 4 days ago
3380
              <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
3381
            </div>`;
Bogdan Timofte authored 4 days ago
3382
          }).join('')
3383
        : '<div class="ca-empty muted">No database tables found.</div>';
3384
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3385
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3386
          if (!isAuthLost(e)) msg(e.message);
3387
        }));
3388
      });
Bogdan Timofte authored 4 days ago
3389
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3390
        button.addEventListener('click', async () => {
3391
          try {
3392
            await copyText(button.dataset.debugTableRef || '');
3393
            msg('table reference copied');
3394
          } catch (e) {
3395
            msg('copy failed');
3396
          }
3397
        });
3398
      });
3399
    }
3400

            
3401
    function debugTableReference(database, tableName) {
3402
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3403
    }
3404

            
3405
    async function selectDebugTable(tableName) {
3406
      state.debugTable = tableName || '';
3407
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3408
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3409
        const card = button.closest('.debug-table-card');
3410
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3411
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3412
      });
3413
      if (state.debugTable) await renderDebugTable(state.debugTable);
3414
    }
3415

            
3416
    function clearDebugTable() {
3417
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3418
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3419
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3420
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3421
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3422
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3423
    }
3424

            
3425
    async function renderDebugTable(tableName) {
3426
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3427
      if (data.error) throw new Error(data.error);
3428
      $('debug-table-stats').innerHTML = [
3429
        ['table', data.table || tableName],
3430
        ['rows', data.row_count || 0],
3431
        ['shown', (data.rows || []).length],
3432
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3433
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3434
      renderDebugRows(data);
3435
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3436
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3437
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3438
    }
3439

            
Bogdan Timofte authored 4 days ago
3440
    function updateDebugExportLinks(tableName) {
3441
      const encoded = encodeURIComponent(tableName || '');
3442
      [
3443
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3444
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3445
      ].forEach(([id, href]) => {
3446
        const link = $(id);
3447
        const enabled = !!tableName;
3448
        link.href = enabled ? href : '#';
3449
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3450
      });
3451
    }
3452

            
Bogdan Timofte authored 4 days ago
3453
    function renderDebugRows(data) {
3454
      const rows = data.rows || [];
3455
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3456
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3457
    }
3458

            
3459
    function renderDebugObjectTable(rows, preferredKeys) {
3460
      const keys = preferredKeys && preferredKeys.length
3461
        ? preferredKeys
3462
        : Array.from(rows.reduce((set, row) => {
3463
            Object.keys(row || {}).forEach(key => set.add(key));
3464
            return set;
3465
          }, new Set()));
3466
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3467
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3468
      const body = rows.length
3469
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3470
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3471
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3472
    }
3473

            
3474
    function debugCell(value) {
3475
      if (value === null || value === undefined) return 'NULL';
3476
      if (Array.isArray(value)) return value.join(', ');
3477
      if (typeof value === 'object') return JSON.stringify(value);
3478
      return String(value);
3479
    }
3480

            
Xdev Host Manager authored a week ago
3481
    async function updateWorkOrderChecklist(id, itemId, checked) {
3482
      try {
3483
        await api('/api/work-orders/checklist', {
3484
          method: 'POST',
3485
          headers: { 'Content-Type': 'application/json' },
3486
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3487
        });
3488
        msg('work order updated');
3489
        await refresh();
Bogdan Timofte authored 4 days ago
3490
      } catch (e) {
3491
        if (isAuthLost(e)) return;
3492
        msg(e.message);
3493
        await refresh().catch(refreshError => {
3494
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3495
        });
3496
      }
Xdev Host Manager authored a week ago
3497
    }
3498

            
Xdev Host Manager authored a week ago
3499
    async function confirmWorkOrder(id) {
3500
      const typed = prompt(`Type ${id} to confirm this work order`);
3501
      if (typed !== id) return;
3502
      try {
3503
        await api('/api/work-orders/confirm', {
3504
          method: 'POST',
3505
          headers: { 'Content-Type': 'application/json' },
3506
          body: JSON.stringify({ id, confirm: typed })
3507
        });
3508
        msg('work order confirmed; local-hosts.tsv written');
3509
        await refresh();
Bogdan Timofte authored 4 days ago
3510
      } catch (e) {
3511
        if (isAuthLost(e)) return;
3512
        msg(e.message);
3513
      }
Xdev Host Manager authored a week ago
3514
    }
3515

            
Xdev Host Manager authored a week ago
3516
    function renderHosts() {
3517
      const filter = $('filter').value.toLowerCase();
3518
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3519
        .slice()
3520
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3521
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3522
        .map(h => {
3523
          const problems = state.problems.filter(p => p.host_id === h.id);
3524
          const cls = problems.length ? 'warn' : 'ok';
3525
          return `<tr data-id="${escapeHtml(h.id)}">
3526
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
Bogdan Timofte authored 4 days ago
3527
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
3528
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3529
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3530
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3531
            <td>${escapeHtml(h.status || '')}</td>
3532
          </tr>`;
3533
        }).join('');
Bogdan Timofte authored 4 days ago
3534
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3535
        editHost(button.dataset.edit).catch(e => {
3536
          if (!isAuthLost(e)) msg(e.message);
3537
        });
3538
      }));
Xdev Host Manager authored a week ago
3539
    }
3540

            
Bogdan Timofte authored 4 days ago
3541
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3542
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3543
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3544
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3545
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3546
    }
3547

            
3548
    function vhostRows() {
3549
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3550
        vhost,
3551
        host_id: host.id || '',
3552
        host_fqdn: host.fqdn || '',
3553
        ip: host.ip || '',
3554
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3555
        monitoring: host.monitoring || '',
3556
        status: host.status || '',
3557
      })));
3558
    }
3559

            
3560
    function renderVhosts() {
3561
      const input = $('vhost-filter');
3562
      const filter = input ? input.value.toLowerCase() : '';
3563
      const rows = vhostRows()
3564
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3565
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3566
      $('vhost-stats').innerHTML = [
3567
        ['shown', rows.length],
3568
        ['total', vhostRows().length],
3569
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3570
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3571
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
Bogdan Timofte authored 4 days ago
3572
        <td>
3573
          <div class="vhost-host">
3574
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3575
              ${renderVhostHostOptions(row.host_fqdn)}
3576
            </select>
3577
            <div class="vhost-host-actions">
3578
              <button type="button" data-vhost-apply="${escapeHtml(row.vhost)}">Move</button>
3579
            </div>
3580
            <div class="mono">${escapeHtml(row.host_id)}</div>
3581
          </div>
3582
        </td>
Bogdan Timofte authored 4 days ago
3583
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
3584
        <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
3585
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3586
        <td>${escapeHtml(row.status)}</td>
3587
      </tr>`).join('') : '<tr><td colspan="6" class="muted">No vhosts.</td></tr>';
Bogdan Timofte authored 4 days ago
3588
      document.querySelectorAll('[data-vhost-apply]').forEach(button => {
3589
        button.addEventListener('click', () => {
3590
          const vhost = button.dataset.vhostApply || '';
3591
          const select = document.querySelector(`[data-vhost-select="${vhost}"]`);
3592
          if (!select) return;
3593
          reassignVhostFromSelect(select, button).catch(e => {
3594
            if (!isAuthLost(e)) msg(e.message);
3595
            select.value = select.dataset.currentHost || '';
3596
          });
Bogdan Timofte authored 4 days ago
3597
        });
Bogdan Timofte authored 4 days ago
3598
      });
3599
    }
3600

            
3601
    function renderVhostHostOptions(selectedHostFqdn) {
3602
      return state.hosts
3603
        .slice()
3604
        .filter(host => (host.status || '') !== 'retired')
3605
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3606
        .map(host => {
3607
          const fqdn = host.fqdn || '';
3608
          const label = [host.id || '', fqdn].filter(Boolean).join(' — ');
3609
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
3610
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(label)}</option>`;
3611
        }).join('');
Bogdan Timofte authored 4 days ago
3612
    }
3613

            
3614
    function shortAliasForFqdn(name) {
3615
      const suffix = '.madagascar.xdev.ro';
3616
      name = String(name || '').toLowerCase();
3617
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3618
    }
3619

            
Bogdan Timofte authored 4 days ago
3620
    async function reassignVhostFromSelect(select, button) {
3621
      const vhost = select.dataset.vhostSelect || '';
3622
      const fromHost = select.dataset.currentHost || '';
3623
      const toHost = select.value || '';
3624
      if (!vhost || !toHost || toHost === fromHost) return;
3625
      select.disabled = true;
3626
      if (button) button.disabled = true;
3627
      try {
3628
        await api('/api/vhosts/reassign', {
3629
          method: 'POST',
3630
          headers: { 'Content-Type': 'application/json' },
3631
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3632
        });
3633
        msg(`vhost ${vhost} moved`);
3634
        await refresh();
3635
      } finally {
3636
        select.disabled = false;
3637
        if (button) button.disabled = false;
3638
      }
3639
    }
3640

            
Bogdan Timofte authored 4 days ago
3641
    async function editHost(id) {
3642
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
3643
      const host = state.hosts.find(h => h.id === id);
3644
      if (!host) return;
3645
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3646
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3647
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3648
      hostField('aliases').value = (host.aliases || []).join('\n');
3649
      hostField('vhosts').value = (host.vhosts || []).join('\n');
Bogdan Timofte authored 5 days ago
3650
      hostField('roles').value = (host.roles || []).join(' ');
3651
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3652
      openHostModal('Edit host');
3653
    }
3654

            
Bogdan Timofte authored 4 days ago
3655
    async function newHost() {
3656
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 5 days ago
3657
      const form = $('host-form');
3658
      form.reset();
Bogdan Timofte authored 5 days ago
3659
      clearHostFormMessage();
3660
      hostField('status').value = 'active';
3661
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3662
      openHostModal('New host');
3663
    }
3664

            
3665
    function openHostModal(title) {
3666
      $('host-modal-title').textContent = title || 'Edit host';
3667
      $('host-modal').hidden = false;
3668
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3669
      hostFormSnapshot = hostFormState();
3670
      hostField('id').focus();
3671
    }
3672

            
3673
    function requestCloseHostModal() {
3674
      if ($('save-host').disabled) return;
3675
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3676
      closeHostModal();
Bogdan Timofte authored 5 days ago
3677
    }
3678

            
3679
    function closeHostModal() {
3680
      $('host-modal').hidden = true;
3681
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3682
      setHostFormBusy(false);
3683
      clearHostFormMessage();
3684
      hostFormSnapshot = '';
3685
    }
3686

            
3687
    function hostField(name) {
3688
      return $('host-form').elements.namedItem(name);
3689
    }
3690

            
3691
    function hostFormState() {
3692
      return JSON.stringify(formObject($('host-form')));
3693
    }
3694

            
3695
    function hostFormDirty() {
3696
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3697
    }
3698

            
3699
    function setHostFormBusy(busy) {
3700
      $('save-host').disabled = busy;
3701
      $('delete-host').disabled = busy;
3702
      $('close-host-modal').disabled = busy;
3703
    }
3704

            
3705
    function setHostFormMessage(text, isError = false) {
3706
      const message = $('host-form-message');
3707
      message.textContent = text || '';
3708
      message.classList.toggle('error', !!isError);
3709
    }
3710

            
3711
    function clearHostFormMessage() {
3712
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3713
    }
3714

            
3715
    function formObject(form) {
3716
      return Object.fromEntries(new FormData(form).entries());
3717
    }
3718

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

            
Bogdan Timofte authored 6 days ago
3724
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3725

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

            
3731
    if (loginAccount) {
3732
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3733
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3734
      loginAccount.addEventListener('input', () => {
3735
        const value = (loginAccount.value || '').trim();
3736
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3737
      });
3738
    }
3739

            
Xdev Host Manager authored a week ago
3740
    function setOtpDigit(idx, value) {
3741
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3742
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3743
      otpDigits[idx].classList.toggle('filled', !!digit);
3744
    }
3745

            
Bogdan Timofte authored 4 days ago
3746
    // Move focus to the next empty box: forward from idx, then wrapping to the
3747
    // start. This lets out-of-order entry continue (e.g. after the last box,
3748
    // jump back to the first still-empty box). Stays put when all boxes are full.
3749
    function advanceFocus(idx) {
3750
      for (let i = idx + 1; i < otpDigits.length; i++) {
3751
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3752
      }
3753
      for (let i = 0; i <= idx; i++) {
3754
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3755
      }
3756
    }
3757

            
Bogdan Timofte authored 4 days ago
3758
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3759
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3760
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
3761
      const digits = (text || '').replace(/\D/g, '').split('');
3762
      if (!digits.length) return;
3763
      let last = startIdx;
3764
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3765
        last = startIdx + i;
3766
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3767
      }
Bogdan Timofte authored 4 days ago
3768
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3769
      advanceFocus(last);
Xdev Host Manager authored a week ago
3770
      maybeSubmitOtp();
3771
    }
3772

            
Bogdan Timofte authored 4 days ago
3773
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3774
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3775
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3776
    function maybeSubmitOtp() {
3777
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3778
    }
3779
    function clearOtp() {
Bogdan Timofte authored 4 days ago
3780
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3781
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3782
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3783
      // an unknown operator, so Safari's autofill anchor on the username stays.
3784
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3785
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3786
    }
3787

            
Bogdan Timofte authored 4 days ago
3788
    otpDigits.forEach((input, idx) => {
3789
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3790
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3791
        // A single box may receive several digits at once (autofill / typing fast).
3792
        if (input.value.replace(/\D/g, '').length > 1) {
3793
          fillOtp(input.value, idx);
3794
          return;
3795
        }
3796
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
3797
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3798
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
3799
        maybeSubmitOtp();
3800
      });
Bogdan Timofte authored 4 days ago
3801

            
3802
      input.addEventListener('paste', (e) => {
3803
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3804
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3805
        const text = (e.clipboardData || window.clipboardData).getData('text');
3806
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3807
      });
Bogdan Timofte authored 4 days ago
3808

            
3809
      input.addEventListener('keydown', (e) => {
3810
        if (e.key === 'Backspace') {
3811
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3812
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3813
          if (input.value) { setOtpDigit(idx, ''); }
3814
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3815
          syncOtpFields();
3816
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3817
          e.preventDefault();
3818
          otpDigits[idx - 1].focus();
3819
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3820
          e.preventDefault();
3821
          otpDigits[idx + 1].focus();
3822
        }
3823
      });
3824
    });
3825

            
Bogdan Timofte authored 4 days ago
3826
    // Focus the first OTP box only for a returning operator (username known).
3827
    // For an unknown operator, leave focus on the username field so Safari can
3828
    // present its OTP autofill anchored there without being dismissed by a focus
3829
    // change (pbx-admin pattern).
3830
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3831
    else if (loginAccount) loginAccount.focus();
3832
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3833

            
Bogdan Timofte authored 5 days ago
3834
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
3835
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
3836
        event.preventDefault();
Bogdan Timofte authored 4 days ago
3837
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
3838
        showPage(link.dataset.pageLink, true);
3839
      });
3840
    });
3841

            
Bogdan Timofte authored 4 days ago
3842
    window.addEventListener('popstate', () => {
3843
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3844
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3845
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3846
    });
Bogdan Timofte authored 5 days ago
3847

            
Bogdan Timofte authored 4 days ago
3848
    async function copyText(text) {
3849
      if (navigator.clipboard && window.isSecureContext) {
3850
        await navigator.clipboard.writeText(text);
3851
        return;
3852
      }
3853
      const input = document.createElement('textarea');
3854
      input.value = text;
3855
      input.setAttribute('readonly', '');
3856
      input.style.position = 'fixed';
3857
      input.style.left = '-10000px';
3858
      document.body.appendChild(input);
3859
      input.select();
3860
      document.execCommand('copy');
3861
      document.body.removeChild(input);
3862
    }
3863

            
3864
    $('copy-build').addEventListener('click', async () => {
3865
      try {
3866
        await copyText($('copy-build').dataset.buildDetails || '');
3867
        if (state.authenticated) msg('build details copied');
3868
      } catch (e) {
3869
        if (state.authenticated) msg('copy failed');
3870
      }
3871
    });
3872

            
Xdev Host Manager authored a week ago
3873
    $('login-form').addEventListener('submit', async (event) => {
3874
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3875
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3876
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3877
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3878
      try {
Xdev Host Manager authored a week ago
3879
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3880
        await refresh();
Xdev Host Manager authored a week ago
3881
      } catch (e) {
3882
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3883
      } finally {
Xdev Host Manager authored a week ago
3884
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3885
      }
Xdev Host Manager authored a week ago
3886
    });
3887

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

            
Bogdan Timofte authored 4 days ago
3893
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3894
      if (!isAuthLost(e)) msg(e.message);
3895
    }));
Xdev Host Manager authored a week ago
3896
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
3897
    $('vhost-filter').addEventListener('input', renderVhosts);
3898
    $('new-host').addEventListener('click', () => {
3899
      newHost().catch(e => {
3900
        if (!isAuthLost(e)) msg(e.message);
3901
      });
3902
    });
Bogdan Timofte authored 4 days ago
3903
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
3904
      if (!isAuthLost(e)) msg(e.message);
3905
    }));
Bogdan Timofte authored 5 days ago
3906
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3907
    $('host-modal').addEventListener('click', (event) => {
3908
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3909
    });
Bogdan Timofte authored 5 days ago
3910
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3911
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3912
    });
Xdev Host Manager authored a week ago
3913

            
Xdev Host Manager authored a week ago
3914
    $('host-form').addEventListener('submit', async (event) => {
3915
      event.preventDefault();
Bogdan Timofte authored 4 days ago
3916
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
3917
      setHostFormBusy(true);
3918
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3919
      try {
3920
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3921
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3922
        closeHostModal();
Xdev Host Manager authored a week ago
3923
        msg('host saved');
3924
        await refresh();
Bogdan Timofte authored 5 days ago
3925
      } catch (e) {
Bogdan Timofte authored 4 days ago
3926
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3927
        setHostFormMessage(e.message, true);
3928
        msg(e.message);
3929
      } finally {
3930
        setHostFormBusy(false);
3931
      }
3932
    });
3933

            
3934
    $('host-form').addEventListener('invalid', (event) => {
3935
      setHostFormMessage('Complete the required host fields before saving.', true);
3936
    }, true);
3937

            
3938
    $('host-form').addEventListener('input', () => {
3939
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3940
    });
3941

            
3942
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3943
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3944
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3945
      setHostFormBusy(true);
3946
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3947
      try {
3948
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3949
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3950
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3951
        closeHostModal();
Xdev Host Manager authored a week ago
3952
        msg('host deleted');
3953
        await refresh();
Bogdan Timofte authored 5 days ago
3954
      } catch (e) {
Bogdan Timofte authored 4 days ago
3955
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
3956
        setHostFormMessage(e.message, true);
3957
        msg(e.message);
3958
      } finally {
3959
        setHostFormBusy(false);
3960
      }
Xdev Host Manager authored a week ago
3961
    });
3962

            
3963
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3964
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3965
      try {
3966
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3967
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
3968
      } catch (e) {
3969
        if (!isAuthLost(e)) msg(e.message);
3970
      }
Xdev Host Manager authored a week ago
3971
    });
3972

            
Bogdan Timofte authored 4 days ago
3973
    refresh().catch(e => {
3974
      if (!isAuthLost(e)) showLogin(e.message);
3975
    });
Xdev Host Manager authored a week ago
3976
  </script>
3977
</body>
3978
</html>
3979
HTML
Bogdan Timofte authored 6 days ago
3980
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3981
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3982
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3983
    return $html;
Xdev Host Manager authored a week ago
3984
}