LocalAuthority / scripts / host_manager.pl
Newer Older
3403 lines | 129.296kb
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
    }
Xdev Host Manager authored a week ago
160
    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
Bogdan Timofte authored 4 days ago
161
        my $registry = load_registry();
162
        return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
Xdev Host Manager authored a week ago
163
    }
164
    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
165
        my $registry = load_registry();
166
        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
167
    }
168
    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
169
        my $registry = load_registry();
170
        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
171
    }
Xdev Host Manager authored a week ago
172
    if ($method eq 'GET' && $path eq '/api/ca/status') {
173
        return send_json_raw($client, 200, ca_manager_json('status-json'));
174
    }
175
    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
176
        return send_json_raw($client, 200, ca_manager_json('list-json'));
177
    }
178
    if ($method eq 'GET' && $path eq '/download/ca.crt') {
179
        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
180
    }
Bogdan Timofte authored 5 days ago
181
    if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
182
        my $name = $1;
183
        return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
184
    }
Xdev Host Manager authored a week ago
185

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

            
212
    return send_json($client, 404, { error => 'not_found' });
213
}
214

            
Bogdan Timofte authored 5 days ago
215
sub app_page_path {
216
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
217
    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
218
}
219

            
Xdev Host Manager authored a week ago
220
sub load_registry {
Bogdan Timofte authored 4 days ago
221
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
222
    normalize_registry_policy($registry);
223
    return $registry;
Xdev Host Manager authored a week ago
224
}
225

            
226
sub save_registry {
227
    my ($registry) = @_;
228
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
229
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
230
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
231
}
232

            
Xdev Host Manager authored a week ago
233
sub load_work_orders {
Bogdan Timofte authored 4 days ago
234
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
235
}
236

            
237
sub save_work_orders {
238
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
239
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
240
}
241

            
242
sub work_orders_payload {
243
    my ($orders) = @_;
244
    my $pending = 0;
245
    for my $wo (@{ $orders->{work_orders} || [] }) {
246
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
247
    }
248
    return {
249
        version => $orders->{version},
250
        work_orders => $orders->{work_orders} || [],
251
        counts => {
252
            work_orders => scalar @{ $orders->{work_orders} || [] },
253
            pending => $pending,
254
        },
255
    };
256
}
257

            
258
sub confirm_work_order {
259
    my ($client, $payload) = @_;
260
    my $id = clean_scalar($payload->{id} || '');
261
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
262
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
263

            
264
    my $orders = load_work_orders();
265
    my $work_order;
266
    for my $wo (@{ $orders->{work_orders} || [] }) {
267
        if (($wo->{id} || '') eq $id) {
268
            $work_order = $wo;
269
            last;
270
        }
271
    }
272
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
273
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
274
    my $incomplete = incomplete_work_order_items($work_order);
275
    return send_json($client, 409, {
276
        error => 'work_order_incomplete',
277
        incomplete => $incomplete,
278
    }) if @$incomplete;
Xdev Host Manager authored a week ago
279

            
280
    my $registry = load_registry();
281
    my $results = apply_work_order($registry, $work_order);
282
    $work_order->{status} = 'confirmed';
283
    $work_order->{confirmed_at} = iso_now();
284
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
285

            
286
    save_registry($registry);
287
    save_work_orders($orders);
288
    backup_file($opt{local_hosts_tsv});
289
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
290

            
291
    return send_json($client, 200, {
292
        ok => json_bool(1),
293
        work_order => $work_order,
294
        results => $results,
295
        local_hosts_tsv => $opt{local_hosts_tsv},
296
    });
297
}
298

            
Xdev Host Manager authored a week ago
299
sub update_work_order_checklist {
300
    my ($client, $payload) = @_;
301
    my $id = clean_scalar($payload->{id} || '');
302
    my $item_id = clean_scalar($payload->{item_id} || '');
303
    my $status = clean_scalar($payload->{status} || '');
304
    my $notes = clean_scalar($payload->{notes} || '');
305
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
306
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
307
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
308

            
309
    my $orders = load_work_orders();
310
    my $work_order;
311
    for my $wo (@{ $orders->{work_orders} || [] }) {
312
        if (($wo->{id} || '') eq $id) {
313
            $work_order = $wo;
314
            last;
315
        }
316
    }
317
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
318
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
319

            
320
    my $item;
321
    for my $candidate (@{ $work_order->{checklist} || [] }) {
322
        if (($candidate->{id} || '') eq $item_id) {
323
            $item = $candidate;
324
            last;
325
        }
326
    }
327
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
328

            
329
    $item->{status} = $status;
330
    $item->{updated_at} = iso_now();
331
    $item->{notes} = $notes if length $notes;
332
    save_work_orders($orders);
333
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
334
}
335

            
336
sub incomplete_work_order_items {
337
    my ($work_order) = @_;
338
    my @incomplete;
339
    for my $item (@{ $work_order->{checklist} || [] }) {
340
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
341
    }
342
    return \@incomplete;
343
}
344

            
Xdev Host Manager authored a week ago
345
sub apply_work_order {
346
    my ($registry, $work_order) = @_;
347
    my @results;
348
    for my $action (@{ $work_order->{actions} || [] }) {
349
        my $type = $action->{type} || '';
350
        if ($type eq 'remove_name') {
351
            my $host_id = $action->{host_id} || '';
352
            my $name = $action->{name} || '';
353
            my $removed = 0;
354
            for my $host (@{ $registry->{hosts} || [] }) {
355
                next unless ($host->{id} || '') eq $host_id;
356
                my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
357
                $removed = @kept != @{ $host->{names} || [] };
358
                $host->{names} = \@kept;
359
                last;
360
            }
361
            push @results, {
362
                type => $type,
363
                host_id => $host_id,
364
                name => $name,
365
                removed => json_bool($removed),
366
            };
367
        } else {
368
            die "Unsupported work order action: $type\n";
369
        }
370
    }
371
    return \@results;
372
}
373

            
Xdev Host Manager authored a week ago
374
sub registry_payload {
375
    my ($registry) = @_;
376
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
377
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Xdev Host Manager authored a week ago
378
    return {
379
        version => $registry->{version},
380
        updated_at => $registry->{updated_at},
381
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
382
        hosts => \@hosts,
Xdev Host Manager authored a week ago
383
        problems => $problems,
384
        counts => {
385
            hosts => scalar @{ $registry->{hosts} },
386
            problems => scalar @$problems,
387
        },
388
    };
389
}
390

            
391
sub upsert_host {
392
    my ($client, $payload) = @_;
393
    my $id = clean_id($payload->{id} || '');
394
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
395

            
396
    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
397
    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
398
    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
399

            
Xdev Host Manager authored a week ago
400
    my @names = remove_derived_names(clean_list($payload->{names}));
Xdev Host Manager authored a week ago
401
    return send_json($client, 400, { error => 'missing_names' }) unless @names;
402

            
403
    my $registry = load_registry();
404
    my %host = (
405
        id => $id,
406
        status => clean_scalar($payload->{status} || 'active'),
407
        hosts_ip => $hosts_ip,
408
        dns_ip => $dns_ip,
409
        names => \@names,
410
        roles => [ clean_list($payload->{roles}) ],
411
        sources => [ clean_list($payload->{sources}) ],
412
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
413
        notes => clean_scalar($payload->{notes} || ''),
414
    );
415

            
416
    my $replaced = 0;
417
    for my $i (0 .. $#{ $registry->{hosts} }) {
418
        if ($registry->{hosts}->[$i]{id} eq $id) {
419
            $registry->{hosts}->[$i] = \%host;
420
            $replaced = 1;
421
            last;
422
        }
423
    }
424
    push @{ $registry->{hosts} }, \%host unless $replaced;
425
    save_registry($registry);
426
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
427
}
428

            
429
sub delete_host {
430
    my ($client, $id) = @_;
431
    $id = clean_id($id);
432
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
433

            
434
    my $registry = load_registry();
435
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
436
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
437
    $registry->{hosts} = \@kept;
438
    save_registry($registry);
439
    return send_json($client, 200, { ok => json_bool(1) });
440
}
441

            
442
sub analyze_hosts {
443
    my ($hosts) = @_;
444
    my @problems;
445
    my (%names, %ids);
446
    for my $host (@$hosts) {
447
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
448
        my @fqdn = grep { /\.madagascar\.xdev\.ro$/ } @{ $host->{names} || [] };
449
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless @fqdn || ($host->{status} || '') ne 'active';
450
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
451
            if grep { /\.vad\.is\.xdev\.ro$/ } @{ $host->{names} || [] };
452
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
453
            if grep { /^(is|vad|b)-/ } @{ $host->{names} || [] };
454
        for my $name (@{ $host->{names} || [] }) {
455
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
456
        }
Xdev Host Manager authored a week ago
457
        my %declared = map { $_ => 1 } @{ $host->{names} || [] };
458
        for my $derived (derived_names($host)) {
459
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
460
                if $declared{$derived};
461
        }
Xdev Host Manager authored a week ago
462
        if (($host->{hosts_ip} || '') ne ($host->{dns_ip} || '') && ($host->{hosts_ip} || '') ne '127.0.0.1') {
463
            push @problems, problem($host, 'split-ip', 'hosts_ip differs from dns_ip; check that this is intentional');
464
        }
465
    }
466
    return \@problems;
467
}
468

            
Xdev Host Manager authored a week ago
469
sub host_payload {
470
    my ($host) = @_;
471
    my %copy = %$host;
472
    $copy{names} = [ effective_names($host) ];
473
    $copy{declared_names} = [ @{ $host->{names} || [] } ];
474
    $copy{derived_names} = [ derived_names($host) ];
475
    return \%copy;
476
}
477

            
478
sub effective_names {
479
    my ($host) = @_;
480
    my @names = @{ $host->{names} || [] };
481
    push @names, derived_names($host);
482
    return unique_preserve(@names);
483
}
484

            
485
sub derived_names {
486
    my ($host) = @_;
487
    my @derived;
488
    for my $name (@{ $host->{names} || [] }) {
489
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
490
        push @derived, $1 if length $1;
491
    }
492
    return unique_preserve(@derived);
493
}
494

            
495
sub remove_derived_names {
496
    my @names = @_;
497
    my %derived;
498
    for my $name (@names) {
499
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
500
        $derived{$1} = 1;
501
    }
502
    return grep { !$derived{$_} } @names;
503
}
504

            
505
sub unique_preserve {
506
    my @values = @_;
507
    my %seen;
508
    return grep { !$seen{$_}++ } @values;
509
}
510

            
Xdev Host Manager authored a week ago
511
sub problem {
512
    my ($host, $code, $message) = @_;
513
    return { host_id => $host->{id}, code => $code, message => $message };
514
}
515

            
516
sub render_local_hosts_tsv {
517
    my ($registry) = @_;
518
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
519
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
520
    $out .= "#\n";
521
    $out .= "# Format:\n";
522
    $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
523
    $out .= "#\n";
524
    $out .= "# Priority rule:\n";
525
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
526
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
527
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
528
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
529
        next unless ($host->{status} || 'active') eq 'active';
Xdev Host Manager authored a week ago
530
        my @names = effective_names($host);
531
        next unless @names;
532
        $out .= join("\t", $host->{hosts_ip}, $host->{dns_ip}, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
533
    }
534
    return $out;
535
}
536

            
537
sub render_monitoring {
538
    my ($registry) = @_;
539
    my @hosts;
540
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
541
        next unless ($host->{status} || 'active') eq 'active';
542
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
543
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
544
        push @hosts, {
545
            id => $host->{id},
Xdev Host Manager authored a week ago
546
            primary_name => $names[0],
Xdev Host Manager authored a week ago
547
            address => $host->{dns_ip},
Xdev Host Manager authored a week ago
548
            aliases => \@names,
549
            declared_names => [ @{ $host->{names} || [] } ],
550
            derived_names => [ derived_names($host) ],
Xdev Host Manager authored a week ago
551
            roles => [ @{ $host->{roles} || [] } ],
552
            monitoring => $host->{monitoring} || 'pending',
553
            notes => $host->{notes} || '',
554
        };
555
    }
556
    return {
557
        version => $registry->{version},
558
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
559
        source => $opt{db},
Xdev Host Manager authored a week ago
560
        hosts => \@hosts,
561
    };
562
}
563

            
Bogdan Timofte authored 4 days ago
564
sub debug_database_tables_payload {
565
    my $dbh = dbh();
566
    my @tables;
567
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
568
    $sth->execute;
569
    while (my ($name) = $sth->fetchrow_array) {
570
        my $quoted = $dbh->quote_identifier($name);
571
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
572
        push @tables, {
573
            name => $name,
574
            rows => int($count || 0),
575
        };
576
    }
577
    return {
578
        database => $opt{db},
579
        generated_at => iso_now(),
580
        tables => \@tables,
581
        counts => {
582
            tables => scalar @tables,
583
            rows => sum(map { $_->{rows} } @tables),
584
        },
585
    };
586
}
587

            
588
sub debug_database_table_payload {
589
    my ($table, $limit) = @_;
590
    my $dbh = dbh();
591
    $table = clean_scalar($table);
592
    return { error => 'missing_table' } unless length $table;
593
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
594
    $limit = int($limit || 100);
595
    $limit = 1 if $limit < 1;
596
    $limit = 500 if $limit > 500;
597

            
598
    my $quoted = $dbh->quote_identifier($table);
599
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
600
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
601
    my @index_details;
602
    for my $index (@$indexes) {
603
        my $index_name = $index->{name} || '';
604
        next unless length $index_name;
605
        my $quoted_index = $dbh->quote_identifier($index_name);
606
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
607
        push @index_details, {
608
            name => $index_name,
609
            unique => int($index->{unique} || 0),
610
            origin => $index->{origin} || '',
611
            partial => int($index->{partial} || 0),
612
            columns => [ map { $_->{name} || '' } @$index_columns ],
613
        };
614
    }
615
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
616
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
617
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
618

            
619
    return {
620
        database => $opt{db},
621
        table => $table,
622
        generated_at => iso_now(),
623
        limit => $limit,
624
        row_count => int($row_count || 0),
625
        columns => $columns,
626
        indexes => \@index_details,
627
        foreign_keys => $foreign_keys,
628
        rows => $rows,
629
    };
630
}
631

            
632
sub debug_table_exists {
633
    my ($dbh, $table) = @_;
634
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
635
    my ($exists) = $dbh->selectrow_array(
636
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
637
        undef,
638
        $table,
639
    );
640
    return $exists ? 1 : 0;
641
}
642

            
643
sub sum {
644
    my $total = 0;
645
    $total += $_ || 0 for @_;
646
    return $total;
647
}
648

            
Xdev Host Manager authored a week ago
649
sub ca_script_path {
650
    return "$project_dir/scripts/ca_manager.sh";
651
}
652

            
653
sub ca_dir {
654
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
655
}
656

            
657
sub ca_cert_path {
658
    return ca_dir() . "/certs/ca.cert.pem";
659
}
660

            
Bogdan Timofte authored 5 days ago
661
sub ca_issued_cert_path {
662
    my ($name) = @_;
663
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
664
    return ca_dir() . "/issued/$name.cert.pem";
665
}
666

            
Xdev Host Manager authored a week ago
667
sub ca_manager_json {
668
    my ($command) = @_;
669
    my $script = ca_script_path();
670
    die "CA manager script is missing\n" unless -x $script;
671
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
672
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
673
    local $/;
674
    my $out = <$fh>;
675
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
676
    $out ||= $command eq 'list-json' ? '[]' : '{}';
677
    sync_certificates_from_json($out) if $command eq 'list-json';
678
    return $out;
679
}
680

            
681
sub sync_certificates_from_json {
682
    my ($json) = @_;
683
    my $certs = eval { json_decode($json || '[]') };
684
    return if $@ || ref($certs) ne 'ARRAY';
685
    my $dbh = dbh();
686
    my $now = iso_now();
687
    with_transaction($dbh, sub {
688
        for my $cert (@$certs) {
689
            next unless ref($cert) eq 'HASH';
690
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
691
            next unless $name;
692
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
693
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
694
            my $cert_path = ca_issued_cert_path($name);
695
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
696
            my $serial = clean_scalar($cert->{serial} || '');
697
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
698
            $dbh->do(
699
                '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) '
700
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
701
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
702
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
703
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
704
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
705
                undef,
706
                $name,
707
                $host_fqdn || undef,
708
                $dns_names[0] || '',
709
                clean_scalar($cert->{subject} || ''),
710
                clean_scalar($cert->{issuer} || ''),
711
                length($serial) ? $serial : undef,
712
                clean_scalar($cert->{not_before} || ''),
713
                clean_scalar($cert->{not_after} || ''),
714
                length($fingerprint) ? $fingerprint : undef,
715
                $cert_path,
716
                $csr_path,
717
                $now,
718
                $now,
719
            );
720
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
721
            for my $dns_name (@dns_names) {
722
                next unless length $dns_name;
723
                $dbh->do(
724
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
725
                    undef,
726
                    $name,
727
                    $dns_name,
728
                );
729
            }
730
        }
731
    });
732
}
733

            
734
sub infer_certificate_host_fqdn {
735
    my ($dbh, $dns_names) = @_;
736
    for my $name (@$dns_names) {
737
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
738
        return $fqdn if $fqdn;
739
    }
740
    for my $name (@$dns_names) {
741
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
742
        return $fqdn if $fqdn;
743
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
744
        return $fqdn if $fqdn;
745
    }
746
    for my $name (@$dns_names) {
747
        return $name if $name =~ /\./;
748
    }
749
    return '';
Xdev Host Manager authored a week ago
750
}
751

            
Xdev Host Manager authored a week ago
752
sub parse_hosts_yaml {
753
    my ($text) = @_;
754
    my %registry = (
755
        version => 1,
756
        updated_at => '',
757
        policy => {},
758
        hosts => [],
759
    );
760
    my ($section, $current, $list_key);
761
    for my $line (split /\n/, $text) {
762
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
763
        if ($line =~ /^version:\s*(\d+)/) {
764
            $registry{version} = int($1);
765
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
766
            $registry{updated_at} = yaml_unquote($1);
767
        } elsif ($line =~ /^policy:\s*$/) {
768
            $section = 'policy';
769
        } elsif ($line =~ /^hosts:\s*$/) {
770
            $section = 'hosts';
771
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
772
            $registry{policy}{$1} = yaml_unquote($2);
773
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
774
            $current = {
775
                id => yaml_unquote($1),
776
                status => 'active',
777
                hosts_ip => '',
778
                dns_ip => '',
779
                names => [],
780
                roles => [],
781
                sources => [],
782
                monitoring => 'pending',
783
                notes => '',
784
            };
785
            push @{ $registry{hosts} }, $current;
786
            $list_key = undef;
787
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
788
            $list_key = $1;
789
            $current->{$list_key} ||= [];
790
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
791
            push @{ $current->{$list_key} }, yaml_unquote($1);
792
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
793
            $current->{$1} = yaml_unquote($2);
794
            $list_key = undef;
795
        }
796
    }
797
    return \%registry;
798
}
799

            
800
sub render_hosts_yaml {
801
    my ($registry) = @_;
802
    my $out = "version: " . int($registry->{version} || 1) . "\n";
803
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
804
    $out .= "policy:\n";
805
    for my $key (sort keys %{ $registry->{policy} || {} }) {
806
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
807
    }
808
    $out .= "hosts:\n";
809
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
810
        $out .= "  - id: " . yq($host->{id}) . "\n";
811
        for my $key (qw(status hosts_ip dns_ip)) {
812
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
813
        }
814
        for my $key (qw(names roles sources)) {
815
            $out .= "    $key:\n";
816
            for my $value (@{ $host->{$key} || [] }) {
817
                $out .= "      - " . yq($value) . "\n";
818
            }
819
        }
820
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
821
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
822
    }
823
    return $out;
824
}
825

            
Xdev Host Manager authored a week ago
826
sub parse_work_orders_yaml {
827
    my ($text) = @_;
828
    my %orders = (
829
        version => 1,
830
        work_orders => [],
831
    );
Xdev Host Manager authored a week ago
832
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
833
    for my $line (split /\n/, $text) {
834
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
835
        if ($line =~ /^version:\s*(\d+)/) {
836
            $orders{version} = int($1);
837
        } elsif ($line =~ /^work_orders:\s*$/) {
838
            $section = 'work_orders';
839
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
840
            $current = {
841
                id => yaml_unquote($1),
842
                status => 'pending',
Xdev Host Manager authored a week ago
843
                checklist => [],
Xdev Host Manager authored a week ago
844
                actions => [],
845
            };
846
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
847
            $list_section = '';
Xdev Host Manager authored a week ago
848
            $current_action = undef;
Xdev Host Manager authored a week ago
849
            $current_item = undef;
850
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
851
            $list_section = 'checklist';
852
            $current->{checklist} ||= [];
853
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
854
            $current_item = { id => yaml_unquote($1), status => 'pending' };
855
            push @{ $current->{checklist} }, $current_item;
856
            $current_action = undef;
857
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
858
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
859
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
860
            $list_section = 'actions';
Xdev Host Manager authored a week ago
861
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
862
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
863
            $current_action = { type => yaml_unquote($1) };
864
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
865
            $current_item = undef;
866
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
867
            $current_action->{$1} = yaml_unquote($2);
868
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
869
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
870
            $list_section = '';
Xdev Host Manager authored a week ago
871
            $current_action = undef;
Xdev Host Manager authored a week ago
872
            $current_item = undef;
Xdev Host Manager authored a week ago
873
        }
874
    }
875
    return \%orders;
876
}
877

            
878
sub render_work_orders_yaml {
879
    my ($orders) = @_;
880
    my $out = "version: " . int($orders->{version} || 1) . "\n";
881
    $out .= "work_orders:\n";
882
    for my $wo (@{ $orders->{work_orders} || [] }) {
883
        $out .= "  - id: " . yq($wo->{id}) . "\n";
884
        for my $key (qw(status title reason created_at confirmed_at result)) {
885
            next unless exists $wo->{$key} && length($wo->{$key} || '');
886
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
887
        }
Xdev Host Manager authored a week ago
888
        $out .= "    checklist:\n";
889
        for my $item (@{ $wo->{checklist} || [] }) {
890
            $out .= "      - id: " . yq($item->{id}) . "\n";
891
            for my $key (qw(text status owner notes updated_at)) {
892
                next unless exists $item->{$key} && length($item->{$key} || '');
893
                $out .= "        $key: " . yq($item->{$key}) . "\n";
894
            }
895
        }
Xdev Host Manager authored a week ago
896
        $out .= "    actions:\n";
897
        for my $action (@{ $wo->{actions} || [] }) {
898
            $out .= "      - type: " . yq($action->{type}) . "\n";
899
            for my $key (qw(host_id name)) {
900
                next unless exists $action->{$key} && length($action->{$key} || '');
901
                $out .= "        $key: " . yq($action->{$key}) . "\n";
902
            }
903
        }
904
    }
905
    return $out;
906
}
907

            
Xdev Host Manager authored a week ago
908
sub request_payload {
909
    my ($headers, $body) = @_;
910
    my $type = $headers->{'content-type'} || '';
911
    if ($type =~ m{application/json}) {
912
        return json_decode($body || '{}');
913
    }
914
    return { parse_params($body || '') };
915
}
916

            
917
sub json_bool {
918
    my ($value) = @_;
919
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
920
}
921

            
922
sub json_encode {
923
    my ($value) = @_;
924
    if (!defined $value) {
925
        return 'null';
926
    }
927
    my $ref = ref($value);
928
    if (!$ref) {
929
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
930
        return json_string($value);
931
    }
932
    if ($ref eq 'HostManager::JSONBool') {
933
        return $$value ? 'true' : 'false';
934
    }
935
    if ($ref eq 'ARRAY') {
936
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
937
    }
938
    if ($ref eq 'HASH') {
939
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
940
    }
941
    return json_string("$value");
942
}
943

            
944
sub json_string {
945
    my ($value) = @_;
946
    $value = '' unless defined $value;
947
    $value =~ s/\\/\\\\/g;
948
    $value =~ s/"/\\"/g;
949
    $value =~ s/\n/\\n/g;
950
    $value =~ s/\r/\\r/g;
951
    $value =~ s/\t/\\t/g;
952
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
953
    return qq("$value");
954
}
955

            
956
sub json_decode {
957
    my ($text) = @_;
958
    my $i = 0;
959
    my $len = length($text);
960
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
961

            
962
    $skip_ws = sub {
963
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
964
    };
965

            
966
    $parse_string = sub {
967
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
968
        $i++;
969
        my $out = '';
970
        while ($i < $len) {
971
            my $ch = substr($text, $i++, 1);
972
            return $out if $ch eq '"';
973
            if ($ch eq "\\") {
974
                die "Bad JSON escape\n" if $i >= $len;
975
                my $esc = substr($text, $i++, 1);
976
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
977
                    $out .= $esc;
978
                } elsif ($esc eq 'b') {
979
                    $out .= "\b";
980
                } elsif ($esc eq 'f') {
981
                    $out .= "\f";
982
                } elsif ($esc eq 'n') {
983
                    $out .= "\n";
984
                } elsif ($esc eq 'r') {
985
                    $out .= "\r";
986
                } elsif ($esc eq 't') {
987
                    $out .= "\t";
988
                } elsif ($esc eq 'u') {
989
                    my $hex = substr($text, $i, 4);
990
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
991
                    $out .= chr(hex($hex));
992
                    $i += 4;
993
                } else {
994
                    die "Bad JSON escape\n";
995
                }
996
            } else {
997
                $out .= $ch;
998
            }
999
        }
1000
        die "Unterminated JSON string\n";
1001
    };
1002

            
1003
    $parse_number = sub {
1004
        my $start = $i;
1005
        $i++ if substr($text, $i, 1) eq '-';
1006
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1007
        if ($i < $len && substr($text, $i, 1) eq '.') {
1008
            $i++;
1009
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1010
        }
1011
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1012
            $i++;
1013
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1014
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1015
        }
1016
        return 0 + substr($text, $start, $i - $start);
1017
    };
1018

            
1019
    $parse_array = sub {
1020
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1021
        $i++;
1022
        my @out;
1023
        $skip_ws->();
1024
        if ($i < $len && substr($text, $i, 1) eq ']') {
1025
            $i++;
1026
            return \@out;
1027
        }
1028
        while (1) {
1029
            push @out, $parse_value->();
1030
            $skip_ws->();
1031
            my $ch = substr($text, $i++, 1);
1032
            last if $ch eq ']';
1033
            die "Expected JSON array comma\n" unless $ch eq ',';
1034
        }
1035
        return \@out;
1036
    };
1037

            
1038
    $parse_object = sub {
1039
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1040
        $i++;
1041
        my %out;
1042
        $skip_ws->();
1043
        if ($i < $len && substr($text, $i, 1) eq '}') {
1044
            $i++;
1045
            return \%out;
1046
        }
1047
        while (1) {
1048
            $skip_ws->();
1049
            my $key = $parse_string->();
1050
            $skip_ws->();
1051
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1052
            $out{$key} = $parse_value->();
1053
            $skip_ws->();
1054
            my $ch = substr($text, $i++, 1);
1055
            last if $ch eq '}';
1056
            die "Expected JSON object comma\n" unless $ch eq ',';
1057
        }
1058
        return \%out;
1059
    };
1060

            
1061
    $parse_value = sub {
1062
        $skip_ws->();
1063
        die "Unexpected end of JSON\n" if $i >= $len;
1064
        my $ch = substr($text, $i, 1);
1065
        return $parse_string->() if $ch eq '"';
1066
        return $parse_object->() if $ch eq '{';
1067
        return $parse_array->() if $ch eq '[';
1068
        if (substr($text, $i, 4) eq 'true') {
1069
            $i += 4;
1070
            return json_bool(1);
1071
        }
1072
        if (substr($text, $i, 5) eq 'false') {
1073
            $i += 5;
1074
            return json_bool(0);
1075
        }
1076
        if (substr($text, $i, 4) eq 'null') {
1077
            $i += 4;
1078
            return undef;
1079
        }
1080
        return $parse_number->() if $ch =~ /[-0-9]/;
1081
        die "Unexpected JSON token\n";
1082
    };
1083

            
1084
    my $value = $parse_value->();
1085
    $skip_ws->();
1086
    die "Trailing JSON content\n" if $i != $len;
1087
    return $value;
1088
}
1089

            
1090
sub parse_params {
1091
    my ($text) = @_;
1092
    my %out;
1093
    for my $pair (split /&/, $text) {
1094
        next unless length $pair;
1095
        my ($k, $v) = split /=/, $pair, 2;
1096
        $out{url_decode($k)} = url_decode($v || '');
1097
    }
1098
    return %out;
1099
}
1100

            
1101
sub clean_id {
1102
    my ($value) = @_;
1103
    $value = lc clean_scalar($value);
1104
    $value =~ s/[^a-z0-9_.-]+/-/g;
1105
    $value =~ s/^-+|-+$//g;
1106
    return $value;
1107
}
1108

            
1109
sub clean_scalar {
1110
    my ($value) = @_;
1111
    $value = '' unless defined $value;
1112
    $value =~ s/[\r\n\t]+/ /g;
1113
    $value =~ s/^\s+|\s+$//g;
1114
    return $value;
1115
}
1116

            
1117
sub clean_list {
1118
    my ($value) = @_;
1119
    return () unless defined $value;
1120
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1121
    my @clean;
1122
    for my $item (@items) {
1123
        $item = clean_scalar($item);
1124
        push @clean, $item if length $item;
1125
    }
1126
    return @clean;
1127
}
1128

            
1129
sub yq {
1130
    my ($value) = @_;
1131
    $value = '' unless defined $value;
1132
    $value =~ s/\\/\\\\/g;
1133
    $value =~ s/"/\\"/g;
1134
    return qq("$value");
1135
}
1136

            
1137
sub yaml_unquote {
1138
    my ($value) = @_;
1139
    $value = '' unless defined $value;
1140
    $value =~ s/^\s+|\s+$//g;
1141
    if ($value =~ /^"(.*)"$/) {
1142
        $value = $1;
1143
        $value =~ s/\\"/"/g;
1144
        $value =~ s/\\\\/\\/g;
1145
    }
1146
    return $value;
1147
}
1148

            
1149
sub verify_totp {
1150
    my ($secret, $otp) = @_;
1151
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1152
    my $key = eval { base32_decode($secret) };
1153
    return 0 if $@ || !length $key;
1154
    my $counter = int(time() / 30);
1155
    for my $offset (-1, 0, 1) {
1156
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1157
    }
1158
    return 0;
1159
}
1160

            
1161
sub totp_code {
1162
    my ($key, $counter) = @_;
1163
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1164
    my $hash = hmac_sha1($msg, $key);
1165
    my $offset = ord(substr($hash, -1)) & 0x0f;
1166
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1167
    return sprintf('%06d', $bin % 1_000_000);
1168
}
1169

            
1170
sub base32_decode {
1171
    my ($text) = @_;
1172
    $text = uc($text || '');
1173
    $text =~ s/[^A-Z2-7]//g;
1174
    my %map;
1175
    my @chars = ('A'..'Z', '2'..'7');
1176
    @map{@chars} = (0..31);
1177
    my ($bits, $value, $out) = (0, 0, '');
1178
    for my $char (split //, $text) {
1179
        die "Invalid base32\n" unless exists $map{$char};
1180
        $value = ($value << 5) | $map{$char};
1181
        $bits += 5;
1182
        while ($bits >= 8) {
1183
            $bits -= 8;
1184
            $out .= chr(($value >> $bits) & 0xff);
1185
        }
1186
    }
1187
    return $out;
1188
}
1189

            
1190
sub create_session {
1191
    my $nonce = random_hex(24);
1192
    my $expires = int(time() + 8 * 3600);
1193
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1194
    my $token = "$nonce:$expires:$sig";
1195
    $sessions{$token} = $expires;
1196
    return $token;
1197
}
1198

            
1199
sub is_authenticated {
1200
    my ($headers) = @_;
1201
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1202
    return 0 unless $token;
1203
    my ($nonce, $expires, $sig) = split /:/, $token;
1204
    return 0 unless $nonce && $expires && $sig;
1205
    return 0 if $expires < time();
1206
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1207
    return exists $sessions{$token};
1208
}
1209

            
1210
sub expire_session {
1211
    my ($headers) = @_;
1212
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1213
    delete $sessions{$token} if $token;
1214
}
1215

            
1216
sub cookie_value {
1217
    my ($cookie, $name) = @_;
1218
    for my $part (split /;\s*/, $cookie) {
1219
        my ($k, $v) = split /=/, $part, 2;
1220
        return $v if defined $k && $k eq $name;
1221
    }
1222
    return '';
1223
}
1224

            
1225
sub send_json {
1226
    my ($client, $status, $payload, $extra_headers) = @_;
1227
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1228
}
1229

            
Xdev Host Manager authored a week ago
1230
sub send_json_raw {
1231
    my ($client, $status, $json_body, $extra_headers) = @_;
1232
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1233
}
1234

            
Xdev Host Manager authored a week ago
1235
sub send_html {
1236
    my ($client, $status, $html) = @_;
1237
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1238
}
1239

            
1240
sub send_text {
1241
    my ($client, $status, $text) = @_;
1242
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1243
}
1244

            
1245
sub send_download {
1246
    my ($client, $status, $content, $type, $filename) = @_;
1247
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1248
}
1249

            
1250
sub send_file {
1251
    my ($client, $path, $type, $filename) = @_;
1252
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1253
    return send_download($client, 200, read_file($path), $type, $filename);
1254
}
1255

            
1256
sub send_response {
1257
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1258
    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
1259
    $body = '' unless defined $body;
1260
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1261
    print $client "Content-Type: $type\r\n";
1262
    print $client "Content-Length: " . length($body) . "\r\n";
1263
    print $client "Cache-Control: no-store\r\n";
1264
    print $client "$_\r\n" for @{ $extra_headers || [] };
1265
    print $client "Connection: close\r\n\r\n";
1266
    print $client $body;
1267
}
1268

            
1269
sub read_file {
1270
    my ($path) = @_;
1271
    open my $fh, '<', $path or die "Cannot read $path: $!";
1272
    local $/;
1273
    return <$fh>;
1274
}
1275

            
1276
sub write_file {
1277
    my ($path, $content) = @_;
1278
    open my $fh, '>', $path or die "Cannot write $path: $!";
1279
    print {$fh} $content;
1280
    close $fh or die "Cannot close $path: $!";
1281
}
1282

            
1283
sub backup_file {
1284
    my ($path) = @_;
1285
    return unless -f $path;
1286
    my $backup_dir = "$project_dir/backups/host-manager";
1287
    make_path($backup_dir) unless -d $backup_dir;
1288
    my $name = $path;
1289
    $name =~ s{.*/}{};
1290
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1291
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1292
}
1293

            
Bogdan Timofte authored 4 days ago
1294
my $db_handle;
Bogdan Timofte authored 4 days ago
1295
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1296

            
1297
sub dbh {
1298
    return $db_handle if $db_handle;
1299
    ensure_parent_dir($opt{db});
1300
    $db_handle = DBI->connect(
1301
        "dbi:SQLite:dbname=$opt{db}",
1302
        '',
1303
        '',
1304
        {
1305
            RaiseError => 1,
1306
            PrintError => 0,
1307
            AutoCommit => 1,
1308
            sqlite_unicode => 1,
1309
        },
1310
    ) or die "Cannot open SQLite database $opt{db}\n";
1311
    $db_handle->do('PRAGMA journal_mode = WAL');
1312
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1313
    create_database_schema($db_handle);
1314
    seed_database($db_handle) unless $db_seeded++;
1315
    return $db_handle;
1316
}
1317

            
1318
sub create_database_schema {
1319
    my ($dbh) = @_;
1320
    $dbh->do(<<'SQL');
1321
CREATE TABLE IF NOT EXISTS schema_meta (
1322
    key TEXT PRIMARY KEY,
1323
    value TEXT NOT NULL,
1324
    updated_at TEXT NOT NULL
1325
)
1326
SQL
1327
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1328
CREATE TABLE IF NOT EXISTS documents (
1329
    name TEXT PRIMARY KEY,
1330
    content TEXT NOT NULL,
1331
    updated_at TEXT NOT NULL
1332
)
1333
SQL
Bogdan Timofte authored 4 days ago
1334
    $dbh->do(
1335
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1336
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1337
        undef, 'schema_version', '2', iso_now()
1338
    );
1339
    $dbh->do(<<'SQL');
1340
CREATE TABLE IF NOT EXISTS hosts (
1341
    fqdn TEXT PRIMARY KEY,
1342
    legacy_id TEXT NOT NULL UNIQUE,
1343
    status TEXT NOT NULL DEFAULT 'active',
1344
    hosts_ip TEXT NOT NULL DEFAULT '',
1345
    dns_ip TEXT NOT NULL DEFAULT '',
1346
    monitoring TEXT NOT NULL DEFAULT 'pending',
1347
    notes TEXT NOT NULL DEFAULT '',
1348
    created_at TEXT NOT NULL,
1349
    updated_at TEXT NOT NULL
1350
)
1351
SQL
1352
    $dbh->do(<<'SQL');
1353
CREATE TABLE IF NOT EXISTS host_aliases (
1354
    alias_name TEXT NOT NULL,
1355
    host_fqdn TEXT NOT NULL,
1356
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1357
    status TEXT NOT NULL DEFAULT 'active',
1358
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1359
    created_at TEXT NOT NULL,
1360
    retired_at TEXT,
1361
    notes TEXT NOT NULL DEFAULT '',
1362
    PRIMARY KEY (alias_name, host_fqdn),
1363
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1364
)
1365
SQL
1366
    $dbh->do(<<'SQL');
1367
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1368
ON host_aliases(alias_name)
1369
WHERE status = 'active'
1370
SQL
1371
    $dbh->do(<<'SQL');
1372
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1373
ON host_aliases(host_fqdn, status)
1374
SQL
1375
    $dbh->do(<<'SQL');
1376
CREATE TABLE IF NOT EXISTS host_roles (
1377
    host_fqdn TEXT NOT NULL,
1378
    role TEXT NOT NULL,
1379
    status TEXT NOT NULL DEFAULT 'active',
1380
    created_at TEXT NOT NULL,
1381
    retired_at TEXT,
1382
    PRIMARY KEY (host_fqdn, role),
1383
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1384
)
1385
SQL
1386
    $dbh->do(<<'SQL');
1387
CREATE TABLE IF NOT EXISTS host_sources (
1388
    host_fqdn TEXT NOT NULL,
1389
    source TEXT NOT NULL,
1390
    status TEXT NOT NULL DEFAULT 'active',
1391
    created_at TEXT NOT NULL,
1392
    retired_at TEXT,
1393
    PRIMARY KEY (host_fqdn, source),
1394
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1395
)
1396
SQL
1397
    $dbh->do(<<'SQL');
1398
CREATE TABLE IF NOT EXISTS host_flags (
1399
    host_fqdn TEXT NOT NULL,
1400
    flag TEXT NOT NULL,
1401
    value TEXT NOT NULL DEFAULT '1',
1402
    created_at TEXT NOT NULL,
1403
    updated_at TEXT NOT NULL,
1404
    PRIMARY KEY (host_fqdn, flag),
1405
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1406
)
1407
SQL
1408
    $dbh->do(<<'SQL');
1409
CREATE TABLE IF NOT EXISTS host_ssh (
1410
    host_fqdn TEXT NOT NULL,
1411
    profile_name TEXT NOT NULL DEFAULT 'default',
1412
    username TEXT NOT NULL DEFAULT '',
1413
    port INTEGER NOT NULL DEFAULT 22,
1414
    identity_file TEXT NOT NULL DEFAULT '',
1415
    address TEXT NOT NULL DEFAULT '',
1416
    local_forward_host TEXT NOT NULL DEFAULT '',
1417
    local_forward_port INTEGER,
1418
    remote_forward_host TEXT NOT NULL DEFAULT '',
1419
    remote_forward_port INTEGER,
1420
    notes TEXT NOT NULL DEFAULT '',
1421
    created_at TEXT NOT NULL,
1422
    updated_at TEXT NOT NULL,
1423
    PRIMARY KEY (host_fqdn, profile_name),
1424
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1425
)
1426
SQL
1427
    $dbh->do(<<'SQL');
1428
CREATE TABLE IF NOT EXISTS certificates (
1429
    certificate_id TEXT PRIMARY KEY,
1430
    host_fqdn TEXT,
1431
    common_name TEXT NOT NULL DEFAULT '',
1432
    subject TEXT NOT NULL DEFAULT '',
1433
    issuer TEXT NOT NULL DEFAULT '',
1434
    serial TEXT UNIQUE,
1435
    status TEXT NOT NULL DEFAULT 'issued',
1436
    not_before TEXT NOT NULL DEFAULT '',
1437
    not_after TEXT NOT NULL DEFAULT '',
1438
    fingerprint_sha256 TEXT UNIQUE,
1439
    cert_path TEXT NOT NULL DEFAULT '',
1440
    csr_path TEXT NOT NULL DEFAULT '',
1441
    created_at TEXT NOT NULL,
1442
    updated_at TEXT NOT NULL,
1443
    notes TEXT NOT NULL DEFAULT '',
1444
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1445
)
1446
SQL
1447
    $dbh->do(<<'SQL');
1448
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1449
    certificate_id TEXT NOT NULL,
1450
    dns_name TEXT NOT NULL,
1451
    PRIMARY KEY (certificate_id, dns_name),
1452
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1453
)
1454
SQL
1455
    $dbh->do(<<'SQL');
1456
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1457
ON certificate_dns_names(dns_name)
1458
SQL
1459
    $dbh->do(<<'SQL');
1460
CREATE TABLE IF NOT EXISTS vhosts (
1461
    vhost_fqdn TEXT PRIMARY KEY,
1462
    host_fqdn TEXT NOT NULL,
1463
    status TEXT NOT NULL DEFAULT 'active',
1464
    service_name TEXT NOT NULL DEFAULT '',
1465
    upstream_url TEXT NOT NULL DEFAULT '',
1466
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1467
    certificate_id TEXT,
1468
    notes TEXT NOT NULL DEFAULT '',
1469
    created_at TEXT NOT NULL,
1470
    updated_at TEXT NOT NULL,
1471
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1472
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1473
)
1474
SQL
1475
    $dbh->do(<<'SQL');
1476
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1477
ON vhosts(host_fqdn, status)
1478
SQL
1479
    $dbh->do(<<'SQL');
1480
CREATE TABLE IF NOT EXISTS data_workers (
1481
    worker_id TEXT PRIMARY KEY,
1482
    worker_type TEXT NOT NULL,
1483
    name TEXT NOT NULL DEFAULT '',
1484
    status TEXT NOT NULL DEFAULT 'active',
1485
    source TEXT NOT NULL DEFAULT '',
1486
    last_run_at TEXT,
1487
    notes TEXT NOT NULL DEFAULT '',
1488
    created_at TEXT NOT NULL,
1489
    updated_at TEXT NOT NULL
1490
)
1491
SQL
1492
    $dbh->do(<<'SQL');
1493
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1494
ON data_workers(worker_type, status)
1495
SQL
1496
    $dbh->do(<<'SQL');
1497
CREATE TABLE IF NOT EXISTS dhcp_leases (
1498
    lease_key TEXT PRIMARY KEY,
1499
    worker_id TEXT NOT NULL,
1500
    host_fqdn TEXT,
1501
    observed_name TEXT NOT NULL DEFAULT '',
1502
    ip_address TEXT NOT NULL,
1503
    mac_address TEXT NOT NULL DEFAULT '',
1504
    lease_state TEXT NOT NULL DEFAULT '',
1505
    first_seen TEXT NOT NULL,
1506
    last_seen TEXT NOT NULL,
1507
    raw TEXT NOT NULL DEFAULT '',
1508
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1509
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1510
)
1511
SQL
1512
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1513
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1514
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1515
    $dbh->do(<<'SQL');
1516
CREATE TABLE IF NOT EXISTS mdns_observations (
1517
    observation_key TEXT PRIMARY KEY,
1518
    worker_id TEXT NOT NULL,
1519
    host_fqdn TEXT,
1520
    observed_name TEXT NOT NULL,
1521
    ip_address TEXT NOT NULL,
1522
    rr_type TEXT NOT NULL DEFAULT 'A',
1523
    ttl INTEGER NOT NULL DEFAULT 0,
1524
    first_seen TEXT NOT NULL,
1525
    last_seen TEXT NOT NULL,
1526
    seen_count INTEGER NOT NULL DEFAULT 1,
1527
    last_peer TEXT NOT NULL DEFAULT '',
1528
    raw TEXT NOT NULL DEFAULT '',
1529
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1530
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1531
)
1532
SQL
1533
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1534
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1535
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1536
    $dbh->do(<<'SQL');
1537
CREATE TABLE IF NOT EXISTS work_orders (
1538
    id TEXT PRIMARY KEY,
1539
    status TEXT NOT NULL DEFAULT 'pending',
1540
    title TEXT NOT NULL DEFAULT '',
1541
    reason TEXT NOT NULL DEFAULT '',
1542
    created_at TEXT NOT NULL,
1543
    confirmed_at TEXT NOT NULL DEFAULT '',
1544
    result TEXT NOT NULL DEFAULT '',
1545
    updated_at TEXT NOT NULL
1546
)
1547
SQL
1548
    $dbh->do(<<'SQL');
1549
CREATE TABLE IF NOT EXISTS work_order_checklist (
1550
    work_order_id TEXT NOT NULL,
1551
    item_id TEXT NOT NULL,
1552
    text TEXT NOT NULL DEFAULT '',
1553
    status TEXT NOT NULL DEFAULT 'pending',
1554
    owner TEXT NOT NULL DEFAULT '',
1555
    notes TEXT NOT NULL DEFAULT '',
1556
    updated_at TEXT NOT NULL DEFAULT '',
1557
    PRIMARY KEY (work_order_id, item_id),
1558
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1559
)
1560
SQL
1561
    $dbh->do(<<'SQL');
1562
CREATE TABLE IF NOT EXISTS work_order_actions (
1563
    work_order_id TEXT NOT NULL,
1564
    position INTEGER NOT NULL,
1565
    type TEXT NOT NULL,
1566
    host_fqdn TEXT,
1567
    host_legacy_id TEXT NOT NULL DEFAULT '',
1568
    name TEXT NOT NULL DEFAULT '',
1569
    payload TEXT NOT NULL DEFAULT '',
1570
    PRIMARY KEY (work_order_id, position),
1571
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1572
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1573
)
1574
SQL
Bogdan Timofte authored 4 days ago
1575
}
1576

            
Bogdan Timofte authored 4 days ago
1577
sub seed_database {
1578
    my ($dbh) = @_;
1579
    seed_default_workers($dbh);
1580

            
1581
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1582
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1583
        normalize_registry_policy($registry);
1584
        with_transaction($dbh, sub {
1585
            import_registry_to_db($dbh, $registry, 0);
1586
        });
1587
    }
1588

            
1589
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1590
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1591
        with_transaction($dbh, sub {
1592
            import_work_orders_to_db($dbh, $orders);
1593
        });
1594
    }
1595

            
1596
    seed_mdns_observations_from_yaml($dbh);
1597
}
1598

            
1599
sub with_transaction {
1600
    my ($dbh, $code) = @_;
1601
    return $code->() unless $dbh->{AutoCommit};
1602
    $dbh->begin_work;
1603
    my $ok = eval {
1604
        $code->();
1605
        1;
1606
    };
1607
    if (!$ok) {
1608
        my $err = $@ || 'transaction failed';
1609
        eval { $dbh->rollback };
1610
        die $err;
1611
    }
1612
    $dbh->commit;
1613
}
1614

            
1615
sub db_scalar {
1616
    my ($dbh, $sql, @bind) = @_;
1617
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1618
    return $value || 0;
1619
}
1620

            
1621
sub legacy_document_text {
1622
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1623
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1624
    return $row->{content} if $row && defined $row->{content};
1625
    return read_file($seed_path) if -f $seed_path;
1626
    return $default_text;
1627
}
1628

            
1629
sub load_registry_from_db {
1630
    my $dbh = dbh();
1631
    my $registry = {
1632
        version => 1,
1633
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1634
        policy => {},
1635
        hosts => [],
1636
    };
Bogdan Timofte authored 4 days ago
1637

            
Bogdan Timofte authored 4 days ago
1638
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1639
    $sth->execute;
1640
    while (my $row = $sth->fetchrow_hashref) {
1641
        my $fqdn = $row->{fqdn};
1642
        push @{ $registry->{hosts} }, {
1643
            id => $row->{legacy_id},
1644
            status => $row->{status},
1645
            hosts_ip => $row->{hosts_ip},
1646
            dns_ip => $row->{dns_ip},
1647
            names => [ active_names_for_host($dbh, $fqdn) ],
1648
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1649
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1650
            monitoring => $row->{monitoring},
1651
            notes => $row->{notes},
1652
        };
1653
    }
1654

            
1655
    return $registry;
Bogdan Timofte authored 4 days ago
1656
}
1657

            
Bogdan Timofte authored 4 days ago
1658
sub save_registry_to_db {
1659
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1660
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1661
    with_transaction($dbh, sub {
1662
        import_registry_to_db($dbh, $registry, 1);
1663
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1664
    });
1665
}
1666

            
1667
sub import_registry_to_db {
1668
    my ($dbh, $registry, $retire_missing) = @_;
1669
    my %seen;
1670
    for my $host (@{ $registry->{hosts} || [] }) {
1671
        my $fqdn = upsert_host_to_db($dbh, $host);
1672
        $seen{$fqdn} = 1 if $fqdn;
1673
    }
1674

            
1675
    return unless $retire_missing;
1676
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1677
    $sth->execute('retired');
1678
    while (my ($fqdn) = $sth->fetchrow_array) {
1679
        next if $seen{$fqdn};
1680
        retire_host_in_db($dbh, $fqdn);
1681
    }
1682
}
1683

            
1684
sub upsert_host_to_db {
1685
    my ($dbh, $host) = @_;
1686
    my $now = iso_now();
1687
    my $fqdn = canonical_host_fqdn($host);
1688
    return '' unless $fqdn;
1689
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1690
    my $status = clean_scalar($host->{status} || 'active');
1691
    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1692
    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1693
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1694
    my $notes = clean_scalar($host->{notes} || '');
1695

            
Bogdan Timofte authored 4 days ago
1696
    $dbh->do(
Bogdan Timofte authored 4 days ago
1697
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1698
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1699
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1700
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1701
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1702
        undef,
Bogdan Timofte authored 4 days ago
1703
        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1704
    );
1705

            
1706
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1707
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1708
    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1709
    return $fqdn;
1710
}
1711

            
1712
sub sync_host_values {
1713
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1714
    my $now = iso_now();
1715
    my %active = map { $_ => 1 } @$values;
1716
    for my $value (@$values) {
1717
        $dbh->do(
1718
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1719
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1720
            undef,
1721
            $fqdn, $value, $now,
1722
        );
1723
    }
1724

            
1725
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1726
    $sth->execute($fqdn);
1727
    while (my ($value) = $sth->fetchrow_array) {
1728
        next if $active{$value};
1729
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1730
    }
1731
}
1732

            
1733
sub sync_host_names {
1734
    my ($dbh, $fqdn, $names) = @_;
1735
    my $now = iso_now();
1736
    my (%aliases, %vhosts);
1737
    if (my $short = short_alias_for_fqdn($fqdn)) {
1738
        $aliases{$short} = 1;
1739
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1740
    }
1741
    for my $name (@$names) {
1742
        $name = normalize_dns_name($name);
1743
        next unless length $name;
1744
        next if $name eq $fqdn;
1745
        if (name_is_vhost($name)) {
1746
            $vhosts{$name} = 1;
1747
            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1748
            if (my $short = short_alias_for_fqdn($name)) {
1749
                $aliases{$short} = 1;
1750
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1751
            }
1752
        } else {
1753
            $aliases{$name} = 1;
1754
            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1755
            if (my $short = short_alias_for_fqdn($name)) {
1756
                $aliases{$short} = 1;
1757
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1758
            }
1759
        }
1760
    }
1761

            
1762
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1763
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1764
}
1765

            
1766
sub upsert_alias_to_db {
1767
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1768
    $dbh->do(
1769
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1770
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1771
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1772
        undef,
1773
        $alias, $fqdn, $kind, $now,
1774
    );
1775
}
1776

            
1777
sub upsert_vhost_to_db {
1778
    my ($dbh, $fqdn, $vhost, $now) = @_;
1779
    my $service = vhost_service_name($vhost);
1780
    $dbh->do(
1781
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
1782
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
1783
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
1784
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
1785
        undef,
1786
        $vhost, $fqdn, $service, $now, $now,
1787
    );
1788
}
1789

            
1790
sub retire_missing_names {
1791
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
1792
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1793
    $sth->execute($fqdn);
1794
    while (my ($name) = $sth->fetchrow_array) {
1795
        next if $active->{$name};
1796
        if ($table eq 'host_aliases') {
1797
            $dbh->do(
1798
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
1799
                undef, $now, $fqdn, $name,
1800
            );
1801
        } else {
1802
            $dbh->do(
1803
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
1804
                undef, $now, $fqdn, $name,
1805
            );
1806
        }
1807
    }
1808
}
1809

            
1810
sub retire_host_in_db {
1811
    my ($dbh, $fqdn) = @_;
1812
    my $now = iso_now();
1813
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
1814
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1815
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1816
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1817
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1818
}
1819

            
1820
sub active_names_for_host {
1821
    my ($dbh, $fqdn) = @_;
1822
    my @names = ($fqdn);
1823
    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");
1824
    $aliases->execute($fqdn);
1825
    while (my ($name) = $aliases->fetchrow_array) {
1826
        push @names, $name;
1827
    }
1828
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1829
    $vhosts->execute($fqdn);
1830
    while (my ($name) = $vhosts->fetchrow_array) {
1831
        push @names, $name;
1832
    }
1833
    return unique_preserve(@names);
1834
}
1835

            
1836
sub active_values_for_host {
1837
    my ($dbh, $table, $column, $fqdn) = @_;
1838
    my @values;
1839
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
1840
    $sth->execute($fqdn);
1841
    while (my ($value) = $sth->fetchrow_array) {
1842
        push @values, $value;
1843
    }
1844
    return @values;
1845
}
1846

            
1847
sub load_work_orders_from_db {
1848
    my $dbh = dbh();
1849
    my $orders = { version => 1, work_orders => [] };
1850
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
1851
    $sth->execute;
1852
    while (my $row = $sth->fetchrow_hashref) {
1853
        my $wo = {
1854
            id => $row->{id},
1855
            status => $row->{status},
1856
            title => $row->{title},
1857
            reason => $row->{reason},
1858
            created_at => $row->{created_at},
1859
            checklist => [],
1860
            actions => [],
1861
        };
1862
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
1863
        $wo->{result} = $row->{result} if length($row->{result} || '');
1864

            
1865
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
1866
        $items->execute($row->{id});
1867
        while (my $item = $items->fetchrow_hashref) {
1868
            my %copy = (
1869
                id => $item->{item_id},
1870
                text => $item->{text},
1871
                status => $item->{status},
1872
            );
1873
            for my $key (qw(owner notes updated_at)) {
1874
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
1875
            }
1876
            push @{ $wo->{checklist} }, \%copy;
1877
        }
1878

            
1879
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
1880
        $actions->execute($row->{id});
1881
        while (my $action = $actions->fetchrow_hashref) {
1882
            my %copy = ( type => $action->{type} );
1883
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
1884
            $copy{name} = $action->{name} if length($action->{name} || '');
1885
            push @{ $wo->{actions} }, \%copy;
1886
        }
1887

            
1888
        push @{ $orders->{work_orders} }, $wo;
1889
    }
1890
    return $orders;
1891
}
1892

            
1893
sub save_work_orders_to_db {
1894
    my ($orders) = @_;
1895
    my $dbh = dbh();
1896
    with_transaction($dbh, sub {
1897
        import_work_orders_to_db($dbh, $orders);
1898
    });
1899
}
1900

            
1901
sub import_work_orders_to_db {
1902
    my ($dbh, $orders) = @_;
1903
    my $now = iso_now();
1904
    my %seen;
1905
    for my $wo (@{ $orders->{work_orders} || [] }) {
1906
        my $id = clean_scalar($wo->{id} || '');
1907
        next unless $id;
1908
        $seen{$id} = 1;
1909
        $dbh->do(
1910
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
1911
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
1912
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
1913
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
1914
            undef,
1915
            $id,
1916
            clean_scalar($wo->{status} || 'pending'),
1917
            clean_scalar($wo->{title} || ''),
1918
            clean_scalar($wo->{reason} || ''),
1919
            clean_scalar($wo->{created_at} || $now),
1920
            clean_scalar($wo->{confirmed_at} || ''),
1921
            clean_scalar($wo->{result} || ''),
1922
            $now,
1923
        );
1924
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
1925
        for my $item (@{ $wo->{checklist} || [] }) {
1926
            $dbh->do(
1927
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1928
                undef,
1929
                $id,
1930
                clean_scalar($item->{id} || ''),
1931
                clean_scalar($item->{text} || ''),
1932
                clean_scalar($item->{status} || 'pending'),
1933
                clean_scalar($item->{owner} || ''),
1934
                clean_scalar($item->{notes} || ''),
1935
                clean_scalar($item->{updated_at} || ''),
1936
            );
1937
        }
1938
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
1939
        my $position = 0;
1940
        for my $action (@{ $wo->{actions} || [] }) {
1941
            my $legacy_id = clean_id($action->{host_id} || '');
1942
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
1943
            $dbh->do(
1944
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
1945
                undef,
1946
                $id,
1947
                $position++,
1948
                clean_scalar($action->{type} || ''),
1949
                $host_fqdn || undef,
1950
                $legacy_id,
1951
                normalize_dns_name($action->{name} || ''),
1952
                '',
1953
            );
1954
        }
1955
    }
1956
}
1957

            
1958
sub seed_default_workers {
1959
    my ($dbh) = @_;
1960
    my $now = iso_now();
1961
    my @workers = (
1962
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
1963
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
1964
    );
1965
    for my $worker (@workers) {
1966
        $dbh->do(
1967
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
1968
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
1969
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
1970
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
1971
            undef,
1972
            @$worker,
1973
            $now,
1974
            $now,
1975
        );
1976
    }
1977
}
1978

            
1979
sub seed_mdns_observations_from_yaml {
1980
    my ($dbh) = @_;
1981
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
1982
    my $path = "$project_dir/var/mdns-observations.yaml";
1983
    return unless -f $path;
1984
    my $db = parse_mdns_observations_yaml(read_file($path));
1985
    with_transaction($dbh, sub {
1986
        for my $observation (@{ $db->{observations} || [] }) {
1987
            $dbh->do(
1988
                '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) '
1989
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
1990
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
1991
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
1992
                undef,
1993
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
1994
                clean_scalar($observation->{name} || ''),
1995
                clean_scalar($observation->{ip} || ''),
1996
                int($observation->{ttl} || 0),
1997
                clean_scalar($observation->{first_seen} || iso_now()),
1998
                clean_scalar($observation->{last_seen} || iso_now()),
1999
                int($observation->{seen_count} || 1),
2000
                clean_scalar($observation->{last_peer} || ''),
2001
            );
2002
        }
2003
    });
2004
}
2005

            
2006
sub parse_mdns_observations_yaml {
2007
    my ($text) = @_;
2008
    my %db = ( observations => [] );
2009
    my ($section, $current);
2010
    for my $line (split /\n/, $text || '') {
2011
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2012
        if ($line =~ /^observations:\s*$/) {
2013
            $section = 'observations';
2014
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2015
            $current = { key => yaml_unquote($1) };
2016
            push @{ $db{observations} }, $current;
2017
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2018
            $current->{$1} = yaml_unquote($2);
2019
        }
2020
    }
2021
    return \%db;
2022
}
2023

            
2024
sub set_schema_meta {
2025
    my ($dbh, $key, $value) = @_;
2026
    $dbh->do(
2027
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2028
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2029
        undef,
2030
        $key,
2031
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2032
        iso_now(),
2033
    );
2034
}
2035

            
Bogdan Timofte authored 4 days ago
2036
sub fqdn_for_legacy_id {
2037
    my ($dbh, $legacy_id) = @_;
2038
    return '' unless length($legacy_id || '');
2039
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2040
    return $fqdn || '';
2041
}
2042

            
2043
sub canonical_host_fqdn {
2044
    my ($host) = @_;
2045
    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
2046
    for my $name (@names) {
2047
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2048
    }
2049
    for my $name (@names) {
2050
        return $name if $name =~ /\./ && !name_is_vhost($name);
2051
    }
2052
    for my $name (@names) {
2053
        return $name if $name =~ /\./;
2054
    }
2055
    my $id = clean_id($host->{id} || '');
2056
    return $id ? "$id.madagascar.xdev.ro" : '';
2057
}
2058

            
2059
sub legacy_id_from_fqdn {
2060
    my ($fqdn) = @_;
2061
    $fqdn = normalize_dns_name($fqdn);
2062
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2063
    $fqdn =~ s/\..*\z//;
2064
    return clean_id($fqdn);
2065
}
2066

            
2067
sub normalize_dns_name {
2068
    my ($name) = @_;
2069
    $name = lc clean_scalar($name || '');
2070
    $name =~ s/\.\z//;
2071
    return $name;
2072
}
2073

            
2074
sub name_is_vhost {
2075
    my ($name) = @_;
2076
    $name = normalize_dns_name($name);
2077
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2078
}
2079

            
2080
sub vhost_service_name {
2081
    my ($name) = @_;
2082
    $name = normalize_dns_name($name);
2083
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2084
    return '';
2085
}
2086

            
2087
sub short_alias_for_fqdn {
2088
    my ($name) = @_;
2089
    $name = normalize_dns_name($name);
2090
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2091
    return '';
2092
}
2093

            
Bogdan Timofte authored 4 days ago
2094
sub normalize_registry_policy {
2095
    my ($registry) = @_;
2096
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2097
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2098
    $registry->{policy}{runtime_database} = $opt{db};
2099
}
2100

            
2101
sub default_hosts_yaml {
2102
    return <<'YAML';
2103
version: 1
2104
updated_at: ""
2105
policy:
Bogdan Timofte authored 4 days ago
2106
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2107
hosts:
2108
YAML
2109
}
2110

            
2111
sub default_work_orders_yaml {
2112
    return <<'YAML';
2113
version: 1
2114
work_orders:
2115
YAML
2116
}
2117

            
2118
sub ensure_parent_dir {
2119
    my ($path) = @_;
2120
    my $dir = dirname($path);
2121
    make_path($dir) unless -d $dir;
2122
}
2123

            
Xdev Host Manager authored a week ago
2124
sub url_decode {
2125
    my ($value) = @_;
2126
    $value = '' unless defined $value;
2127
    $value =~ tr/+/ /;
2128
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2129
    return $value;
2130
}
2131

            
2132
sub random_hex {
2133
    my ($bytes) = @_;
2134
    if (open my $fh, '<:raw', '/dev/urandom') {
2135
        read($fh, my $raw, $bytes);
2136
        close $fh;
2137
        return unpack('H*', $raw);
2138
    }
2139
    return sha256_hex(rand() . time() . $$);
2140
}
2141

            
2142
sub iso_now {
2143
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2144
}
2145

            
Bogdan Timofte authored 6 days ago
2146
sub build_info {
2147
    my %info = (
2148
        revision => '',
2149
        branch => '',
2150
        built_at => '',
2151
        deployed_at => '',
2152
        dirty => '',
2153
    );
2154

            
2155
    if ($ENV{HOST_MANAGER_BUILD}) {
2156
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2157
        return \%info;
2158
    }
2159

            
2160
    my $build_file = "$project_dir/BUILD";
2161
    if (-f $build_file) {
2162
        for my $line (split /\n/, read_file($build_file)) {
2163
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2164
            $info{$1} = clean_scalar($2);
2165
        }
2166
        return \%info if $info{revision} || $info{built_at};
2167
    }
2168

            
2169
    my $revision = git_value('rev-parse --short=12 HEAD');
2170
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2171
    $info{revision} = $revision if $revision;
2172
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2173
    return \%info;
2174
}
2175

            
2176
sub git_value {
2177
    my ($args) = @_;
2178
    return '' unless -d "$project_dir/.git";
2179
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2180
    my $value = <$fh> || '';
2181
    close $fh;
2182
    chomp $value;
2183
    return clean_scalar($value);
2184
}
2185

            
2186
sub build_label {
2187
    my $info = build_info();
2188
    my $revision = $info->{revision} || 'unknown';
2189
    my $branch = $info->{branch} || '';
2190
    $branch = '' if $branch eq 'HEAD';
2191
    my $label = $branch ? "$branch $revision" : $revision;
2192
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2193
    return $label;
2194
}
2195

            
2196
sub build_title {
2197
    my $info = build_info();
2198
    my $label = build_label();
2199
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2200
    return $stamp ? "$label deployed $stamp" : $label;
2201
}
2202

            
Bogdan Timofte authored 4 days ago
2203
sub build_revision {
2204
    my $info = build_info();
2205
    return $info->{revision} || 'unknown';
2206
}
2207

            
2208
sub build_details {
2209
    my $info = build_info();
2210
    my %details = (
2211
        app => 'Madagascar Local Authority',
2212
        revision => $info->{revision} || 'unknown',
2213
        branch => $info->{branch} || '',
2214
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2215
        built_at => $info->{built_at} || '',
2216
        deployed_at => $info->{deployed_at} || '',
2217
        label => build_label(),
2218
        title => build_title(),
2219
    );
2220
    return json_encode(\%details);
2221
}
2222

            
Bogdan Timofte authored 6 days ago
2223
sub html_escape {
2224
    my ($value) = @_;
2225
    $value = '' unless defined $value;
2226
    $value =~ s/&/&amp;/g;
2227
    $value =~ s/</&lt;/g;
2228
    $value =~ s/>/&gt;/g;
2229
    $value =~ s/"/&quot;/g;
2230
    $value =~ s/'/&#039;/g;
2231
    return $value;
2232
}
2233

            
Xdev Host Manager authored a week ago
2234
sub app_html {
Bogdan Timofte authored 4 days ago
2235
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2236
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2237
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2238
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2239
<!doctype html>
2240
<html lang="ro">
2241
<head>
2242
  <meta charset="utf-8">
2243
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2244
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2245
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2246
  <style>
2247
    :root {
2248
      color-scheme: light;
2249
      --ink: #152033;
2250
      --muted: #647084;
2251
      --line: #d8dee8;
2252
      --soft: #f4f6f9;
2253
      --panel: #ffffff;
2254
      --accent: #1267d8;
2255
      --bad: #b42318;
2256
      --warn: #946200;
2257
      --ok: #137333;
2258
    }
2259
    * { box-sizing: border-box; }
2260
    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
2261

            
2262
    /* ── Login screen ── */
2263
    #login-screen {
2264
      display: flex;
Xdev Host Manager authored a week ago
2265
      align-items: flex-start;
Xdev Host Manager authored a week ago
2266
      justify-content: center;
2267
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2268
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2269
      background: #13182a;
Xdev Host Manager authored a week ago
2270
      overflow: auto;
Xdev Host Manager authored a week ago
2271
    }
2272
    .login-card {
Xdev Host Manager authored a week ago
2273
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2274
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2275
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2276
      background: #fff;
2277
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2278
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2279
         below the first box, sits inside the card instead of spilling past it. */
2280
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2281
      width: 100%;
Xdev Host Manager authored a week ago
2282
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2283
      min-height: 360px;
Xdev Host Manager authored a week ago
2284
      display: grid;
Xdev Host Manager authored a week ago
2285
      align-content: start;
2286
      justify-items: center;
2287
      gap: 28px;
Xdev Host Manager authored a week ago
2288
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2289
    }
Xdev Host Manager authored a week ago
2290
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2291
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2292
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2293
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2294
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2295
    }
Xdev Host Manager authored a week ago
2296
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2297
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2298
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2299
    .login-card form {
2300
      display: grid;
2301
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2302
      justify-self: center;
Bogdan Timofte authored a week ago
2303
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2304
    }
Xdev Host Manager authored a week ago
2305
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2306
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2307
       giving the password manager a username anchor and an aggregated OTP target
2308
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2309
    .pm-helper-fields {
2310
      position: absolute;
2311
      left: -10000px;
2312
      top: auto;
2313
      width: 1px;
2314
      height: 1px;
2315
      overflow: hidden;
2316
      opacity: 0.01;
2317
    }
2318
    .pm-helper-fields input {
2319
      width: 1px;
2320
      height: 1px;
2321
      padding: 0;
2322
      border: 0;
2323
    }
Bogdan Timofte authored 4 days ago
2324
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2325
       hint was what made Safari mark the whole group and re-present its OTP
2326
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2327
    .otp-row {
2328
      display: flex;
2329
      gap: var(--otp-gap);
2330
      justify-content: center;
2331
    }
Bogdan Timofte authored 5 days ago
2332
    .otp-row input {
Xdev Host Manager authored a week ago
2333
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
2334
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2335
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2336
      transition: border-color .15s, background .15s;
2337
    }
Bogdan Timofte authored 5 days ago
2338
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2339
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2340
    #login-error {
2341
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2342
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2343
    }
2344
    @media (max-width: 760px) {
2345
      .login-card {
Xdev Host Manager authored a week ago
2346
        max-width: 520px;
Xdev Host Manager authored a week ago
2347
        min-height: 0;
Bogdan Timofte authored 4 days ago
2348
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2349
        gap: 26px;
2350
      }
2351
      .login-card .brand h1 { font-size: 24px; }
2352
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2353
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2354
    }
Xdev Host Manager authored a week ago
2355
    @media (max-width: 430px) {
2356
      #login-screen { padding: 24px 16px 120px; }
2357
      .login-card {
2358
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2359
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2360
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2361
      }
Bogdan Timofte authored 5 days ago
2362
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2363
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2364
    }
2365
    @media (max-height: 720px) {
2366
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2367
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2368
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2369
    }
Xdev Host Manager authored a week ago
2370

            
2371
    /* ── App shell (hidden until authenticated) ── */
2372
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2373
    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
2374
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2375
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2376
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2377
    nav a:hover { color: var(--ink); background: var(--soft); }
2378
    nav a.active { color: var(--accent); background: #e8f0fe; }
2379
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2380
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2381
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2382
    .page { display: grid; gap: 16px; }
2383
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2384
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2385
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2386
    .panel { overflow: hidden; }
2387
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2388
    .panel-head h2 { margin: 0; font-size: 14px; }
2389
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2390
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2391
    button, input, select, textarea { font: inherit; }
2392
    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; }
2393
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2394
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2395
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2396
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2397
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2398
    textarea { min-height: 74px; resize: vertical; }
2399
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2400
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2401
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2402
    tr:hover td { background: #f8fafc; }
2403
    .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; }
2404
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2405
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2406
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
2407
    .pill.derived { border-style: dashed; }
Xdev Host Manager authored a week ago
2408
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2409
    .span2 { grid-column: 1 / -1; }
2410
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2411
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2412
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2413
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2414
    .ca-fingerprint { overflow-wrap: anywhere; }
2415
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2416
    .build-control {
Bogdan Timofte authored 6 days ago
2417
      position: fixed;
2418
      right: 10px;
2419
      bottom: 8px;
2420
      z-index: 5;
Bogdan Timofte authored 4 days ago
2421
      display: inline-flex;
2422
      align-items: center;
2423
      gap: 4px;
2424
    }
2425
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2426
      color: rgba(255,255,255,.46);
2427
      background: rgba(19,24,42,.28);
2428
      border: 1px solid rgba(255,255,255,.08);
2429
      border-radius: 4px;
2430
      font-size: 10px;
2431
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2432
    }
2433
    .build-badge {
2434
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
2435
      cursor: text;
2436
      user-select: text;
Bogdan Timofte authored 6 days ago
2437
    }
Bogdan Timofte authored 4 days ago
2438
    .build-copy {
2439
      min-height: 0;
2440
      padding: 2px 5px;
2441
      cursor: pointer;
2442
    }
2443
    .build-copy:hover {
2444
      color: rgba(255,255,255,.72);
2445
      border-color: rgba(255,255,255,.24);
2446
    }
2447
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2448
      color: rgba(100,112,132,.58);
2449
      background: rgba(255,255,255,.72);
2450
      border-color: rgba(216,222,232,.72);
2451
    }
Bogdan Timofte authored 4 days ago
2452
    body.is-app .build-copy:hover {
2453
      color: rgba(21,32,51,.78);
2454
      border-color: rgba(100,112,132,.42);
2455
    }
Xdev Host Manager authored a week ago
2456
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2457
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2458
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2459
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2460
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2461
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2462
    .work-order-actions { gap: 4px; }
2463
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2464
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2465
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2466
    .debug-controls { display: grid; grid-template-columns: minmax(220px, 320px) auto 1fr; gap: 8px; align-items: center; width: 100%; }
2467
    .debug-controls select { min-width: 0; }
2468
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2469
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2470
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2471
    .host-tools input { max-width: 240px; }
2472
    .modal-backdrop {
2473
      position: fixed;
2474
      inset: 0;
2475
      z-index: 10;
2476
      display: grid;
2477
      align-items: start;
2478
      justify-items: center;
2479
      padding: 72px 16px 24px;
2480
      background: rgba(21,32,51,.48);
2481
      overflow: auto;
2482
    }
2483
    .modal-backdrop[hidden] { display: none; }
2484
    .modal {
2485
      width: min(840px, 100%);
2486
      max-height: calc(100dvh - 96px);
2487
      overflow: auto;
2488
      background: var(--panel);
2489
      border: 1px solid var(--line);
2490
      border-radius: 8px;
2491
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2492
    }
2493
    .modal-head {
2494
      position: sticky;
2495
      top: 0;
2496
      z-index: 1;
2497
      display: flex;
2498
      align-items: center;
2499
      justify-content: space-between;
2500
      gap: 12px;
2501
      padding: 12px 14px;
2502
      border-bottom: 1px solid var(--line);
2503
      background: #fafbfc;
2504
    }
2505
    .modal-head h2 { margin: 0; font-size: 14px; }
2506
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2507
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2508
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2509
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2510
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2511
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2512
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2513
      #message { max-width: 100%; }
2514
      .panel-head { align-items: stretch; flex-direction: column; }
2515
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2516
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2517
      .debug-controls { grid-template-columns: 1fr; }
Bogdan Timofte authored 5 days ago
2518
      .modal-backdrop { padding-top: 16px; }
2519
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2520
      .grid { grid-template-columns: 1fr; }
2521
      table { min-width: 760px; }
2522
      .table-wrap { overflow-x: auto; }
2523
    }
2524
  </style>
2525
</head>
Bogdan Timofte authored 6 days ago
2526
<body class="is-login">
Xdev Host Manager authored a week ago
2527

            
Xdev Host Manager authored a week ago
2528
  <!-- ── Login screen ── -->
2529
  <div id="login-screen">
2530
    <div class="login-card">
2531
      <div class="brand">
2532
        <div class="icon">
Xdev Host Manager authored a week ago
2533
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2534
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2535
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2536
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2537
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2538
            <path d="M26 20h8M26 32h8M26 44h8"/>
2539
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2540
          </svg>
2541
        </div>
Xdev Host Manager authored a week ago
2542
        <h1>Madagascar Local Authority</h1>
2543
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2544
      </div>
Bogdan Timofte authored 4 days ago
2545
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2546
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2547
        <div class="pm-helper-fields" aria-hidden="true">
2548
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2549
          <input type="hidden" id="otp-hidden" name="otp">
2550
        </div>
Xdev Host Manager authored a week ago
2551
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2552
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2553
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2554
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2555
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2556
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2557
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2558
        </div>
2559
      </form>
2560
    </div>
2561
  </div>
2562

            
2563
  <!-- ── App (shown after login) ── -->
2564
  <div id="app">
2565
    <header>
Xdev Host Manager authored a week ago
2566
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2567
      <nav aria-label="Sections">
2568
        <a href="/overview" data-page-link="overview">Overview</a>
2569
        <a href="/hosts" data-page-link="hosts">Hosts</a>
2570
        <a href="/dns" data-page-link="dns">DNS</a>
2571
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2572
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2573
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2574
      </nav>
Xdev Host Manager authored a week ago
2575
      <div class="header-right">
2576
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2577
        <span id="message" class="muted"></span>
2578
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2579
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2580
      </div>
Xdev Host Manager authored a week ago
2581
    </header>
2582
    <main>
Bogdan Timofte authored 5 days ago
2583
      <section class="page" id="page-overview" data-page="overview">
2584
        <section class="panel">
2585
          <div class="panel-head">
2586
            <h2>Overview</h2>
2587
            <div class="stats" id="stats"></div>
2588
          </div>
2589
          <div class="problems" id="problems"></div>
2590
        </section>
Xdev Host Manager authored a week ago
2591
      </section>
2592

            
Bogdan Timofte authored 5 days ago
2593
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2594
        <section class="panel">
2595
          <div class="panel-head">
2596
            <h2>Hosts</h2>
2597
            <div class="host-tools">
2598
              <input id="filter" placeholder="filter">
2599
              <button type="button" id="new-host">New host</button>
2600
            </div>
2601
          </div>
2602
          <div class="table-wrap">
2603
            <table>
2604
              <thead>
2605
                <tr>
2606
                  <th style="width: 120px">ID</th>
2607
                  <th style="width: 130px">hosts_ip</th>
2608
                  <th style="width: 130px">dns_ip</th>
2609
                  <th>Names</th>
2610
                  <th style="width: 150px">Roles</th>
2611
                  <th style="width: 110px">Monitoring</th>
2612
                  <th style="width: 90px">Status</th>
2613
                </tr>
2614
              </thead>
2615
              <tbody id="hosts"></tbody>
2616
            </table>
2617
          </div>
2618
        </section>
Xdev Host Manager authored a week ago
2619
      </section>
Xdev Host Manager authored a week ago
2620

            
Bogdan Timofte authored 5 days ago
2621
      <section class="page" id="page-dns" data-page="dns" hidden>
2622
        <section class="toolbar">
2623
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2624
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2625
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2626
          <button id="write-tsv">Write local-hosts.tsv</button>
2627
        </section>
Xdev Host Manager authored a week ago
2628
      </section>
2629

            
Bogdan Timofte authored 5 days ago
2630
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2631
        <section class="panel">
2632
          <div class="panel-head">
2633
            <h2>Work Orders</h2>
2634
            <div class="stats" id="wo-stats"></div>
2635
          </div>
2636
          <div class="problems" id="work-orders"></div>
2637
        </section>
Xdev Host Manager authored a week ago
2638
      </section>
2639

            
Bogdan Timofte authored 5 days ago
2640
      <section class="page" id="page-ca" data-page="ca" hidden>
2641
        <section class="panel">
2642
          <div class="panel-head">
2643
            <h2>Local Certificate Authority</h2>
2644
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2645
          </div>
2646
          <div class="problems" id="ca-status"></div>
2647
        </section>
2648
        <section class="panel">
2649
          <div class="panel-head">
2650
            <h2>Issued Certificates</h2>
2651
            <div class="stats" id="ca-certs-summary"></div>
2652
          </div>
2653
          <div class="table-wrap">
2654
            <table>
2655
              <thead>
2656
                <tr>
2657
                  <th style="width: 150px">Name</th>
2658
                  <th>DNS names</th>
2659
                  <th style="width: 210px">Validity</th>
2660
                  <th style="width: 180px">Serial</th>
2661
                  <th>Fingerprint</th>
2662
                  <th style="width: 110px">Download</th>
2663
                </tr>
2664
              </thead>
2665
              <tbody id="ca-certs"></tbody>
2666
            </table>
2667
          </div>
2668
        </section>
Xdev Host Manager authored a week ago
2669
      </section>
Bogdan Timofte authored 4 days ago
2670

            
2671
      <section class="page" id="page-debug" data-page="debug" hidden>
2672
        <section class="panel">
2673
          <div class="panel-head">
2674
            <h2>Database</h2>
2675
            <div class="stats" id="debug-db-stats"></div>
2676
          </div>
2677
          <div class="toolbar">
2678
            <div class="debug-controls">
2679
              <select id="debug-db-table" aria-label="Database table"></select>
2680
              <button type="button" id="debug-db-refresh">Refresh</button>
2681
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
2682
            </div>
2683
          </div>
2684
        </section>
2685
        <section class="debug-section">
2686
          <section class="panel">
2687
            <div class="panel-head">
2688
              <h2>Rows</h2>
2689
              <div class="stats" id="debug-table-stats"></div>
2690
            </div>
2691
            <div class="table-wrap" id="debug-table-rows"></div>
2692
          </section>
2693
          <section class="panel">
2694
            <div class="panel-head">
2695
              <h2>Columns</h2>
2696
            </div>
2697
            <div class="table-wrap" id="debug-table-columns"></div>
2698
          </section>
2699
          <section class="panel">
2700
            <div class="panel-head">
2701
              <h2>Indexes</h2>
2702
            </div>
2703
            <div class="table-wrap" id="debug-table-indexes"></div>
2704
          </section>
2705
          <section class="panel">
2706
            <div class="panel-head">
2707
              <h2>Foreign Keys</h2>
2708
            </div>
2709
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
2710
          </section>
2711
        </section>
2712
      </section>
Bogdan Timofte authored 5 days ago
2713
    </main>
Xdev Host Manager authored a week ago
2714

            
Bogdan Timofte authored 5 days ago
2715
    <div id="host-modal" class="modal-backdrop" hidden>
2716
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
2717
        <div class="modal-head">
2718
          <h2 id="host-modal-title">Edit host</h2>
2719
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
2720
        </div>
2721
        <form id="host-form" class="grid">
2722
          <label>ID<input name="id" required></label>
2723
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2724
          <label>hosts_ip<input name="hosts_ip" required></label>
2725
          <label>dns_ip<input name="dns_ip" required></label>
2726
          <label class="span2">Names<textarea name="names" required></textarea></label>
2727
          <label>Roles<input name="roles"></label>
2728
          <label>Sources<input name="sources"></label>
2729
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2730
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
2731
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
2732
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
2733
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
2734
            <button class="danger" type="button" id="delete-host">Delete host</button>
2735
          </div>
2736
        </form>
2737
      </section>
Bogdan Timofte authored 5 days ago
2738
    </div>
Xdev Host Manager authored a week ago
2739
  </div>
2740

            
Bogdan Timofte authored 4 days ago
2741
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
2742
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
2743
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
2744
  </div>
Bogdan Timofte authored 6 days ago
2745

            
Xdev Host Manager authored a week ago
2746
  <script>
Xdev Host Manager authored a week ago
2747
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
Bogdan Timofte authored 5 days ago
2748
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
2749

            
2750
    const $ = (id) => document.getElementById(id);
2751
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
2752
    const PAGE_PATHS = {
2753
      '/': 'overview',
2754
      '/overview': 'overview',
2755
      '/hosts': 'hosts',
2756
      '/dns': 'dns',
2757
      '/work-orders': 'work-orders',
2758
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
2759
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
2760
    };
Xdev Host Manager authored a week ago
2761

            
2762
    async function api(path, options = {}) {
2763
      const res = await fetch(path, options);
2764
      const body = await res.json();
2765
      if (!res.ok) throw new Error(body.error || res.statusText);
2766
      return body;
2767
    }
2768

            
Bogdan Timofte authored 5 days ago
2769
    function currentPage() {
2770
      return PAGE_PATHS[window.location.pathname] || 'overview';
2771
    }
2772

            
2773
    function showPage(page, push = false) {
2774
      const target = page || 'overview';
2775
      document.querySelectorAll('[data-page]').forEach(section => {
2776
        section.hidden = section.dataset.page !== target;
2777
      });
2778
      document.querySelectorAll('[data-page-link]').forEach(link => {
2779
        link.classList.toggle('active', link.dataset.pageLink === target);
2780
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
2781
      });
2782
      if (push) {
2783
        const href = target === 'overview' ? '/overview' : '/' + target;
2784
        history.pushState({ page: target }, '', href);
2785
      }
Bogdan Timofte authored 4 days ago
2786
      if (state.authenticated && target === 'debug') {
2787
        renderDebugDatabase().catch(e => msg(e.message));
2788
      }
Bogdan Timofte authored 5 days ago
2789
    }
2790

            
Xdev Host Manager authored a week ago
2791
    function showLogin(errorText) {
Bogdan Timofte authored 6 days ago
2792
      document.body.classList.remove('is-app');
2793
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
2794
      $('app').style.display = 'none';
2795
      $('login-screen').style.display = 'flex';
2796
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
2797
      clearOtp();
Xdev Host Manager authored a week ago
2798
    }
2799

            
2800
    function showApp() {
Bogdan Timofte authored 6 days ago
2801
      document.body.classList.remove('is-login');
2802
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
2803
      $('login-screen').style.display = 'none';
2804
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
2805
      showPage(currentPage());
Xdev Host Manager authored a week ago
2806
    }
2807

            
Xdev Host Manager authored a week ago
2808
    async function refresh() {
2809
      const session = await api('/api/session');
2810
      state.authenticated = session.authenticated;
Xdev Host Manager authored a week ago
2811
      if (!state.authenticated) { showLogin(); return; }
2812
      showApp();
Xdev Host Manager authored a week ago
2813
      const data = await api('/api/hosts');
2814
      state.hosts = data.hosts || [];
2815
      state.problems = data.problems || [];
2816
      render(data);
Xdev Host Manager authored a week ago
2817
      await renderCa();
Xdev Host Manager authored a week ago
2818
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
2819
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
2820
    }
2821

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

            
Xdev Host Manager authored a week ago
2825
      $('stats').innerHTML = [
2826
        ['hosts', data.counts.hosts],
2827
        ['problems', data.counts.problems],
2828
      ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2829

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

            
2834
      renderHosts();
2835
    }
2836

            
Xdev Host Manager authored a week ago
2837
    async function renderCa() {
2838
      try {
2839
        const status = await api('/api/ca/status');
2840
        if (!status.initialized) {
2841
          $('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
2842
          $('ca-certs-summary').innerHTML = '';
2843
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
2844
          return;
2845
        }
2846
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
2847
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
2848
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
2849
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
2850
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
2851
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
2852
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
2853
            <div>
2854
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
2855
              <span>${certs.length} issued certificate(s)</span>
2856
            </div>
Xdev Host Manager authored a week ago
2857
          </div>`;
Bogdan Timofte authored 5 days ago
2858
        $('ca-certs-summary').innerHTML = [
2859
          ['issued', certs.length],
2860
          ['expiring', certs.filter(cert => {
2861
            const days = daysUntil(cert.not_after);
2862
            return days !== null && days >= 0 && days <= 30;
2863
          }).length],
2864
          ['expired', certs.filter(cert => {
2865
            const days = daysUntil(cert.not_after);
2866
            return days !== null && days < 0;
2867
          }).length],
2868
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2869
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
2870
          const days = daysUntil(cert.not_after);
2871
          const dnsNames = cert.dns_names || [];
2872
          const dnsHtml = dnsNames.length
2873
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
2874
            : '<span class="muted">No DNS SANs reported.</span>';
2875
          return `<tr>
2876
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
2877
            <td>${dnsHtml}</td>
2878
            <td>
2879
              <div class="ca-detail">
2880
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
2881
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
2882
              </div>
2883
            </td>
2884
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
2885
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
2886
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
2887
          </tr>`;
2888
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
2889
      } catch (e) {
2890
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
2891
        $('ca-certs-summary').innerHTML = '';
2892
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
2893
      }
2894
    }
2895

            
Bogdan Timofte authored 5 days ago
2896
    function daysUntil(dateText) {
2897
      const time = Date.parse(dateText || '');
2898
      if (!Number.isFinite(time)) return null;
2899
      return Math.ceil((time - Date.now()) / 86400000);
2900
    }
2901

            
2902
    function certStatusClass(days) {
2903
      if (days === null) return '';
2904
      if (days < 0) return 'bad';
2905
      if (days <= 30) return 'warn';
2906
      return 'ok';
2907
    }
2908

            
2909
    function certStatusLabel(days) {
2910
      if (days === null) return 'validity unknown';
2911
      if (days < 0) return 'expired';
2912
      if (days === 0) return 'expires today';
2913
      return `${days}d remaining`;
2914
    }
2915

            
Xdev Host Manager authored a week ago
2916
    async function renderWorkOrders() {
2917
      try {
2918
        const data = await api('/api/work-orders');
2919
        state.workOrders = data.work_orders || [];
2920
        $('wo-stats').innerHTML = [
2921
          ['pending', data.counts.pending],
2922
          ['total', data.counts.work_orders],
2923
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2924

            
2925
        if (!state.workOrders.length) {
2926
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
2927
          return;
2928
        }
2929

            
2930
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
2931
          const checklist = wo.checklist || [];
2932
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
2933
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
2934
          const checklistHtml = checklist.map(item => {
2935
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
2936
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
2937
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
2938
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
2939
            </label>`;
2940
          }).join('');
Xdev Host Manager authored a week ago
2941
          const actions = (wo.actions || []).map(a => {
2942
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
2943
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
2944
          }).join('');
2945
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
2946
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
2947
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
2948
            : '';
Bogdan Timofte authored 6 days ago
2949
          return `<div class="problem work-order-card">
2950
            <div class="work-order-head">
Xdev Host Manager authored a week ago
2951
              <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
2952
              ${button}
2953
            </div>
Bogdan Timofte authored 6 days ago
2954
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
2955
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
2956
            <div class="work-order-checklist">${checklistHtml}</div>
2957
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
2958
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
2959
          </div>`;
2960
        }).join('');
Xdev Host Manager authored a week ago
2961
        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
2962
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
2963
      } catch (e) {
2964
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
2965
      }
2966
    }
2967

            
Bogdan Timofte authored 4 days ago
2968
    async function renderDebugDatabase() {
2969
      if (!state.authenticated) return;
2970
      const data = await api('/api/debug/database/tables');
2971
      const tableSelect = $('debug-db-table');
2972
      const current = tableSelect.value;
2973
      const tables = data.tables || [];
2974
      tableSelect.innerHTML = tables.map(table => `<option value="${escapeHtml(table.name)}">${escapeHtml(table.name)} (${escapeHtml(String(table.rows))})</option>`).join('');
2975
      const selected = tables.some(table => table.name === current) ? current : (tables[0] ? tables[0].name : '');
2976
      tableSelect.value = selected;
2977
      $('debug-db-stats').innerHTML = [
2978
        ['tables', data.counts ? data.counts.tables : tables.length],
2979
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
2980
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
2981
      $('debug-db-meta').textContent = data.database || '';
2982
      if (selected) await renderDebugTable(selected);
2983
    }
2984

            
2985
    async function renderDebugTable(tableName) {
2986
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
2987
      if (data.error) throw new Error(data.error);
2988
      $('debug-table-stats').innerHTML = [
2989
        ['table', data.table || tableName],
2990
        ['rows', data.row_count || 0],
2991
        ['shown', (data.rows || []).length],
2992
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
2993
      renderDebugRows(data);
2994
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
2995
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
2996
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
2997
    }
2998

            
2999
    function renderDebugRows(data) {
3000
      const rows = data.rows || [];
3001
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3002
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3003
    }
3004

            
3005
    function renderDebugObjectTable(rows, preferredKeys) {
3006
      const keys = preferredKeys && preferredKeys.length
3007
        ? preferredKeys
3008
        : Array.from(rows.reduce((set, row) => {
3009
            Object.keys(row || {}).forEach(key => set.add(key));
3010
            return set;
3011
          }, new Set()));
3012
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3013
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3014
      const body = rows.length
3015
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3016
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3017
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3018
    }
3019

            
3020
    function debugCell(value) {
3021
      if (value === null || value === undefined) return 'NULL';
3022
      if (Array.isArray(value)) return value.join(', ');
3023
      if (typeof value === 'object') return JSON.stringify(value);
3024
      return String(value);
3025
    }
3026

            
Xdev Host Manager authored a week ago
3027
    async function updateWorkOrderChecklist(id, itemId, checked) {
3028
      try {
3029
        await api('/api/work-orders/checklist', {
3030
          method: 'POST',
3031
          headers: { 'Content-Type': 'application/json' },
3032
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3033
        });
3034
        msg('work order updated');
3035
        await refresh();
3036
      } catch (e) { msg(e.message); await refresh(); }
3037
    }
3038

            
Xdev Host Manager authored a week ago
3039
    async function confirmWorkOrder(id) {
3040
      const typed = prompt(`Type ${id} to confirm this work order`);
3041
      if (typed !== id) return;
3042
      try {
3043
        await api('/api/work-orders/confirm', {
3044
          method: 'POST',
3045
          headers: { 'Content-Type': 'application/json' },
3046
          body: JSON.stringify({ id, confirm: typed })
3047
        });
3048
        msg('work order confirmed; local-hosts.tsv written');
3049
        await refresh();
3050
      } catch (e) { msg(e.message); }
3051
    }
3052

            
Xdev Host Manager authored a week ago
3053
    function renderHosts() {
3054
      const filter = $('filter').value.toLowerCase();
3055
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
3056
        .slice()
3057
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3058
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3059
        .map(h => {
3060
          const problems = state.problems.filter(p => p.host_id === h.id);
3061
          const cls = problems.length ? 'warn' : 'ok';
3062
          return `<tr data-id="${escapeHtml(h.id)}">
3063
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
3064
            <td>${escapeHtml(h.hosts_ip || '')}</td>
3065
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 5 days ago
3066
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3067
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3068
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3069
            <td>${escapeHtml(h.status || '')}</td>
3070
          </tr>`;
3071
        }).join('');
3072
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
3073
    }
3074

            
Bogdan Timofte authored 5 days ago
3075
    function renderNamePills(host) {
3076
      const declared = host.declared_names || host.names || [];
3077
      const derived = host.derived_names || [];
3078
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3079
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
3080
      return declaredHtml + derivedHtml;
3081
    }
3082

            
Xdev Host Manager authored a week ago
3083
    function editHost(id) {
3084
      const host = state.hosts.find(h => h.id === id);
3085
      if (!host) return;
3086
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3087
      clearHostFormMessage();
3088
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 5 days ago
3089
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 5 days ago
3090
      hostField('roles').value = (host.roles || []).join(' ');
3091
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3092
      openHostModal('Edit host');
3093
    }
3094

            
3095
    function newHost() {
3096
      const form = $('host-form');
3097
      form.reset();
Bogdan Timofte authored 5 days ago
3098
      clearHostFormMessage();
3099
      hostField('status').value = 'active';
3100
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3101
      openHostModal('New host');
3102
    }
3103

            
3104
    function openHostModal(title) {
3105
      $('host-modal-title').textContent = title || 'Edit host';
3106
      $('host-modal').hidden = false;
3107
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3108
      hostFormSnapshot = hostFormState();
3109
      hostField('id').focus();
3110
    }
3111

            
3112
    function requestCloseHostModal() {
3113
      if ($('save-host').disabled) return;
3114
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3115
      closeHostModal();
Bogdan Timofte authored 5 days ago
3116
    }
3117

            
3118
    function closeHostModal() {
3119
      $('host-modal').hidden = true;
3120
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3121
      setHostFormBusy(false);
3122
      clearHostFormMessage();
3123
      hostFormSnapshot = '';
3124
    }
3125

            
3126
    function hostField(name) {
3127
      return $('host-form').elements.namedItem(name);
3128
    }
3129

            
3130
    function hostFormState() {
3131
      return JSON.stringify(formObject($('host-form')));
3132
    }
3133

            
3134
    function hostFormDirty() {
3135
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3136
    }
3137

            
3138
    function setHostFormBusy(busy) {
3139
      $('save-host').disabled = busy;
3140
      $('delete-host').disabled = busy;
3141
      $('close-host-modal').disabled = busy;
3142
    }
3143

            
3144
    function setHostFormMessage(text, isError = false) {
3145
      const message = $('host-form-message');
3146
      message.textContent = text || '';
3147
      message.classList.toggle('error', !!isError);
3148
    }
3149

            
3150
    function clearHostFormMessage() {
3151
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3152
    }
3153

            
3154
    function formObject(form) {
3155
      return Object.fromEntries(new FormData(form).entries());
3156
    }
3157

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

            
Bogdan Timofte authored 6 days ago
3163
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3164

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

            
3170
    if (loginAccount) {
3171
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3172
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3173
      loginAccount.addEventListener('input', () => {
3174
        const value = (loginAccount.value || '').trim();
3175
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3176
      });
3177
    }
3178

            
Xdev Host Manager authored a week ago
3179
    function setOtpDigit(idx, value) {
3180
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
3181
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3182
      otpDigits[idx].classList.toggle('filled', !!digit);
3183
    }
3184

            
Bogdan Timofte authored 4 days ago
3185
    // Move focus to the next empty box: forward from idx, then wrapping to the
3186
    // start. This lets out-of-order entry continue (e.g. after the last box,
3187
    // jump back to the first still-empty box). Stays put when all boxes are full.
3188
    function advanceFocus(idx) {
3189
      for (let i = idx + 1; i < otpDigits.length; i++) {
3190
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3191
      }
3192
      for (let i = 0; i <= idx; i++) {
3193
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3194
      }
3195
    }
3196

            
Bogdan Timofte authored 5 days ago
3197
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3198
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3199
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
3200
      const digits = (text || '').replace(/\D/g, '').split('');
3201
      if (!digits.length) return;
3202
      let last = startIdx;
3203
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3204
        last = startIdx + i;
3205
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3206
      }
Bogdan Timofte authored 5 days ago
3207
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3208
      advanceFocus(last);
Xdev Host Manager authored a week ago
3209
      maybeSubmitOtp();
3210
    }
3211

            
Bogdan Timofte authored 5 days ago
3212
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3213
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3214
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3215
    function maybeSubmitOtp() {
3216
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3217
    }
3218
    function clearOtp() {
Bogdan Timofte authored 5 days ago
3219
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3220
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3221
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3222
      // an unknown operator, so Safari's autofill anchor on the username stays.
3223
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3224
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3225
    }
3226

            
Bogdan Timofte authored 5 days ago
3227
    otpDigits.forEach((input, idx) => {
3228
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3229
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3230
        // A single box may receive several digits at once (autofill / typing fast).
3231
        if (input.value.replace(/\D/g, '').length > 1) {
3232
          fillOtp(input.value, idx);
3233
          return;
3234
        }
3235
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
3236
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3237
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
3238
        maybeSubmitOtp();
3239
      });
Bogdan Timofte authored 5 days ago
3240

            
3241
      input.addEventListener('paste', (e) => {
3242
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3243
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3244
        const text = (e.clipboardData || window.clipboardData).getData('text');
3245
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
3246
      });
Bogdan Timofte authored 5 days ago
3247

            
3248
      input.addEventListener('keydown', (e) => {
3249
        if (e.key === 'Backspace') {
3250
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3251
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3252
          if (input.value) { setOtpDigit(idx, ''); }
3253
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3254
          syncOtpFields();
3255
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3256
          e.preventDefault();
3257
          otpDigits[idx - 1].focus();
3258
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3259
          e.preventDefault();
3260
          otpDigits[idx + 1].focus();
3261
        }
3262
      });
3263
    });
3264

            
Bogdan Timofte authored 4 days ago
3265
    // Focus the first OTP box only for a returning operator (username known).
3266
    // For an unknown operator, leave focus on the username field so Safari can
3267
    // present its OTP autofill anchored there without being dismissed by a focus
3268
    // change (pbx-admin pattern).
3269
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3270
    else if (loginAccount) loginAccount.focus();
3271
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3272

            
Bogdan Timofte authored 5 days ago
3273
    document.querySelectorAll('[data-page-link]').forEach(link => {
3274
      link.addEventListener('click', (event) => {
3275
        event.preventDefault();
3276
        showPage(link.dataset.pageLink, true);
3277
      });
3278
    });
3279

            
3280
    window.addEventListener('popstate', () => showPage(currentPage()));
3281

            
Bogdan Timofte authored 4 days ago
3282
    async function copyText(text) {
3283
      if (navigator.clipboard && window.isSecureContext) {
3284
        await navigator.clipboard.writeText(text);
3285
        return;
3286
      }
3287
      const input = document.createElement('textarea');
3288
      input.value = text;
3289
      input.setAttribute('readonly', '');
3290
      input.style.position = 'fixed';
3291
      input.style.left = '-10000px';
3292
      document.body.appendChild(input);
3293
      input.select();
3294
      document.execCommand('copy');
3295
      document.body.removeChild(input);
3296
    }
3297

            
3298
    $('copy-build').addEventListener('click', async () => {
3299
      try {
3300
        await copyText($('copy-build').dataset.buildDetails || '');
3301
        if (state.authenticated) msg('build details copied');
3302
      } catch (e) {
3303
        if (state.authenticated) msg('copy failed');
3304
      }
3305
    });
3306

            
Xdev Host Manager authored a week ago
3307
    $('login-form').addEventListener('submit', async (event) => {
3308
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3309
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3310
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3311
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3312
      try {
Xdev Host Manager authored a week ago
3313
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3314
        await refresh();
Xdev Host Manager authored a week ago
3315
      } catch (e) {
3316
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3317
      } finally {
Xdev Host Manager authored a week ago
3318
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3319
      }
Xdev Host Manager authored a week ago
3320
    });
3321

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

            
Xdev Host Manager authored a week ago
3327
    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
3328
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 5 days ago
3329
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 4 days ago
3330
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => msg(e.message)));
3331
    $('debug-db-table').addEventListener('change', () => renderDebugTable($('debug-db-table').value).catch(e => msg(e.message)));
Bogdan Timofte authored 5 days ago
3332
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3333
    $('host-modal').addEventListener('click', (event) => {
3334
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3335
    });
Bogdan Timofte authored 5 days ago
3336
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3337
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3338
    });
Xdev Host Manager authored a week ago
3339

            
Xdev Host Manager authored a week ago
3340
    $('host-form').addEventListener('submit', async (event) => {
3341
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3342
      setHostFormBusy(true);
3343
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3344
      try {
3345
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3346
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3347
        closeHostModal();
Xdev Host Manager authored a week ago
3348
        msg('host saved');
3349
        await refresh();
Bogdan Timofte authored 5 days ago
3350
      } catch (e) {
3351
        setHostFormMessage(e.message, true);
3352
        msg(e.message);
3353
      } finally {
3354
        setHostFormBusy(false);
3355
      }
3356
    });
3357

            
3358
    $('host-form').addEventListener('invalid', (event) => {
3359
      setHostFormMessage('Complete the required host fields before saving.', true);
3360
    }, true);
3361

            
3362
    $('host-form').addEventListener('input', () => {
3363
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3364
    });
3365

            
3366
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3367
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3368
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3369
      setHostFormBusy(true);
3370
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3371
      try {
3372
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3373
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3374
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3375
        closeHostModal();
Xdev Host Manager authored a week ago
3376
        msg('host deleted');
3377
        await refresh();
Bogdan Timofte authored 5 days ago
3378
      } catch (e) {
3379
        setHostFormMessage(e.message, true);
3380
        msg(e.message);
3381
      } finally {
3382
        setHostFormBusy(false);
3383
      }
Xdev Host Manager authored a week ago
3384
    });
3385

            
3386
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3387
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3388
      try {
3389
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3390
        msg('local-hosts.tsv written');
3391
      } catch (e) { msg(e.message); }
3392
    });
3393

            
Xdev Host Manager authored a week ago
3394
    refresh().catch(() => showLogin());
Xdev Host Manager authored a week ago
3395
  </script>
3396
</body>
3397
</html>
3398
HTML
Bogdan Timofte authored 6 days ago
3399
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3400
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3401
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3402
    return $html;
Xdev Host Manager authored a week ago
3403
}