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

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

            
206
    return send_json($client, 404, { error => 'not_found' });
207
}
208

            
Bogdan Timofte authored 5 days ago
209
sub app_page_path {
210
    my ($path) = @_;
211
    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca)\z};
212
}
213

            
Xdev Host Manager authored a week ago
214
sub load_registry {
Bogdan Timofte authored 4 days ago
215
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
216
    normalize_registry_policy($registry);
217
    return $registry;
Xdev Host Manager authored a week ago
218
}
219

            
220
sub save_registry {
221
    my ($registry) = @_;
222
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
223
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
224
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
225
}
226

            
Xdev Host Manager authored a week ago
227
sub load_work_orders {
Bogdan Timofte authored 4 days ago
228
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
229
}
230

            
231
sub save_work_orders {
232
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
233
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
234
}
235

            
236
sub work_orders_payload {
237
    my ($orders) = @_;
238
    my $pending = 0;
239
    for my $wo (@{ $orders->{work_orders} || [] }) {
240
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
241
    }
242
    return {
243
        version => $orders->{version},
244
        work_orders => $orders->{work_orders} || [],
245
        counts => {
246
            work_orders => scalar @{ $orders->{work_orders} || [] },
247
            pending => $pending,
248
        },
249
    };
250
}
251

            
252
sub confirm_work_order {
253
    my ($client, $payload) = @_;
254
    my $id = clean_scalar($payload->{id} || '');
255
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
256
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
257

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

            
274
    my $registry = load_registry();
275
    my $results = apply_work_order($registry, $work_order);
276
    $work_order->{status} = 'confirmed';
277
    $work_order->{confirmed_at} = iso_now();
278
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
279

            
280
    save_registry($registry);
281
    save_work_orders($orders);
282
    backup_file($opt{local_hosts_tsv});
283
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
284

            
285
    return send_json($client, 200, {
286
        ok => json_bool(1),
287
        work_order => $work_order,
288
        results => $results,
289
        local_hosts_tsv => $opt{local_hosts_tsv},
290
    });
291
}
292

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

            
303
    my $orders = load_work_orders();
304
    my $work_order;
305
    for my $wo (@{ $orders->{work_orders} || [] }) {
306
        if (($wo->{id} || '') eq $id) {
307
            $work_order = $wo;
308
            last;
309
        }
310
    }
311
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
312
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
313

            
314
    my $item;
315
    for my $candidate (@{ $work_order->{checklist} || [] }) {
316
        if (($candidate->{id} || '') eq $item_id) {
317
            $item = $candidate;
318
            last;
319
        }
320
    }
321
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
322

            
323
    $item->{status} = $status;
324
    $item->{updated_at} = iso_now();
325
    $item->{notes} = $notes if length $notes;
326
    save_work_orders($orders);
327
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
328
}
329

            
330
sub incomplete_work_order_items {
331
    my ($work_order) = @_;
332
    my @incomplete;
333
    for my $item (@{ $work_order->{checklist} || [] }) {
334
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
335
    }
336
    return \@incomplete;
337
}
338

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

            
Xdev Host Manager authored a week ago
368
sub registry_payload {
369
    my ($registry) = @_;
370
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
371
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Xdev Host Manager authored a week ago
372
    return {
373
        version => $registry->{version},
374
        updated_at => $registry->{updated_at},
375
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
376
        hosts => \@hosts,
Xdev Host Manager authored a week ago
377
        problems => $problems,
378
        counts => {
379
            hosts => scalar @{ $registry->{hosts} },
380
            problems => scalar @$problems,
381
        },
382
    };
383
}
384

            
385
sub upsert_host {
386
    my ($client, $payload) = @_;
387
    my $id = clean_id($payload->{id} || '');
388
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
389

            
390
    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
391
    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
392
    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
393

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

            
397
    my $registry = load_registry();
398
    my %host = (
399
        id => $id,
400
        status => clean_scalar($payload->{status} || 'active'),
401
        hosts_ip => $hosts_ip,
402
        dns_ip => $dns_ip,
403
        names => \@names,
404
        roles => [ clean_list($payload->{roles}) ],
405
        sources => [ clean_list($payload->{sources}) ],
406
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
407
        notes => clean_scalar($payload->{notes} || ''),
408
    );
409

            
410
    my $replaced = 0;
411
    for my $i (0 .. $#{ $registry->{hosts} }) {
412
        if ($registry->{hosts}->[$i]{id} eq $id) {
413
            $registry->{hosts}->[$i] = \%host;
414
            $replaced = 1;
415
            last;
416
        }
417
    }
418
    push @{ $registry->{hosts} }, \%host unless $replaced;
419
    save_registry($registry);
420
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
421
}
422

            
423
sub delete_host {
424
    my ($client, $id) = @_;
425
    $id = clean_id($id);
426
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
427

            
428
    my $registry = load_registry();
429
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
430
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
431
    $registry->{hosts} = \@kept;
432
    save_registry($registry);
433
    return send_json($client, 200, { ok => json_bool(1) });
434
}
435

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

            
Xdev Host Manager authored a week ago
463
sub host_payload {
464
    my ($host) = @_;
465
    my %copy = %$host;
466
    $copy{names} = [ effective_names($host) ];
467
    $copy{declared_names} = [ @{ $host->{names} || [] } ];
468
    $copy{derived_names} = [ derived_names($host) ];
469
    return \%copy;
470
}
471

            
472
sub effective_names {
473
    my ($host) = @_;
474
    my @names = @{ $host->{names} || [] };
475
    push @names, derived_names($host);
476
    return unique_preserve(@names);
477
}
478

            
479
sub derived_names {
480
    my ($host) = @_;
481
    my @derived;
482
    for my $name (@{ $host->{names} || [] }) {
483
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
484
        push @derived, $1 if length $1;
485
    }
486
    return unique_preserve(@derived);
487
}
488

            
489
sub remove_derived_names {
490
    my @names = @_;
491
    my %derived;
492
    for my $name (@names) {
493
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
494
        $derived{$1} = 1;
495
    }
496
    return grep { !$derived{$_} } @names;
497
}
498

            
499
sub unique_preserve {
500
    my @values = @_;
501
    my %seen;
502
    return grep { !$seen{$_}++ } @values;
503
}
504

            
Xdev Host Manager authored a week ago
505
sub problem {
506
    my ($host, $code, $message) = @_;
507
    return { host_id => $host->{id}, code => $code, message => $message };
508
}
509

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

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

            
Xdev Host Manager authored a week ago
558
sub ca_script_path {
559
    return "$project_dir/scripts/ca_manager.sh";
560
}
561

            
562
sub ca_dir {
563
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
564
}
565

            
566
sub ca_cert_path {
567
    return ca_dir() . "/certs/ca.cert.pem";
568
}
569

            
Bogdan Timofte authored 5 days ago
570
sub ca_issued_cert_path {
571
    my ($name) = @_;
572
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
573
    return ca_dir() . "/issued/$name.cert.pem";
574
}
575

            
Xdev Host Manager authored a week ago
576
sub ca_manager_json {
577
    my ($command) = @_;
578
    my $script = ca_script_path();
579
    die "CA manager script is missing\n" unless -x $script;
580
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
581
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
582
    local $/;
583
    my $out = <$fh>;
584
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
585
    $out ||= $command eq 'list-json' ? '[]' : '{}';
586
    sync_certificates_from_json($out) if $command eq 'list-json';
587
    return $out;
588
}
589

            
590
sub sync_certificates_from_json {
591
    my ($json) = @_;
592
    my $certs = eval { json_decode($json || '[]') };
593
    return if $@ || ref($certs) ne 'ARRAY';
594
    my $dbh = dbh();
595
    my $now = iso_now();
596
    with_transaction($dbh, sub {
597
        for my $cert (@$certs) {
598
            next unless ref($cert) eq 'HASH';
599
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
600
            next unless $name;
601
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
602
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
603
            my $cert_path = ca_issued_cert_path($name);
604
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
605
            my $serial = clean_scalar($cert->{serial} || '');
606
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
607
            $dbh->do(
608
                '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) '
609
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
610
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
611
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
612
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
613
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
614
                undef,
615
                $name,
616
                $host_fqdn || undef,
617
                $dns_names[0] || '',
618
                clean_scalar($cert->{subject} || ''),
619
                clean_scalar($cert->{issuer} || ''),
620
                length($serial) ? $serial : undef,
621
                clean_scalar($cert->{not_before} || ''),
622
                clean_scalar($cert->{not_after} || ''),
623
                length($fingerprint) ? $fingerprint : undef,
624
                $cert_path,
625
                $csr_path,
626
                $now,
627
                $now,
628
            );
629
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
630
            for my $dns_name (@dns_names) {
631
                next unless length $dns_name;
632
                $dbh->do(
633
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
634
                    undef,
635
                    $name,
636
                    $dns_name,
637
                );
638
            }
639
        }
640
    });
641
}
642

            
643
sub infer_certificate_host_fqdn {
644
    my ($dbh, $dns_names) = @_;
645
    for my $name (@$dns_names) {
646
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
647
        return $fqdn if $fqdn;
648
    }
649
    for my $name (@$dns_names) {
650
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
651
        return $fqdn if $fqdn;
652
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
653
        return $fqdn if $fqdn;
654
    }
655
    for my $name (@$dns_names) {
656
        return $name if $name =~ /\./;
657
    }
658
    return '';
Xdev Host Manager authored a week ago
659
}
660

            
Xdev Host Manager authored a week ago
661
sub parse_hosts_yaml {
662
    my ($text) = @_;
663
    my %registry = (
664
        version => 1,
665
        updated_at => '',
666
        policy => {},
667
        hosts => [],
668
    );
669
    my ($section, $current, $list_key);
670
    for my $line (split /\n/, $text) {
671
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
672
        if ($line =~ /^version:\s*(\d+)/) {
673
            $registry{version} = int($1);
674
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
675
            $registry{updated_at} = yaml_unquote($1);
676
        } elsif ($line =~ /^policy:\s*$/) {
677
            $section = 'policy';
678
        } elsif ($line =~ /^hosts:\s*$/) {
679
            $section = 'hosts';
680
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
681
            $registry{policy}{$1} = yaml_unquote($2);
682
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
683
            $current = {
684
                id => yaml_unquote($1),
685
                status => 'active',
686
                hosts_ip => '',
687
                dns_ip => '',
688
                names => [],
689
                roles => [],
690
                sources => [],
691
                monitoring => 'pending',
692
                notes => '',
693
            };
694
            push @{ $registry{hosts} }, $current;
695
            $list_key = undef;
696
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
697
            $list_key = $1;
698
            $current->{$list_key} ||= [];
699
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
700
            push @{ $current->{$list_key} }, yaml_unquote($1);
701
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
702
            $current->{$1} = yaml_unquote($2);
703
            $list_key = undef;
704
        }
705
    }
706
    return \%registry;
707
}
708

            
709
sub render_hosts_yaml {
710
    my ($registry) = @_;
711
    my $out = "version: " . int($registry->{version} || 1) . "\n";
712
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
713
    $out .= "policy:\n";
714
    for my $key (sort keys %{ $registry->{policy} || {} }) {
715
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
716
    }
717
    $out .= "hosts:\n";
718
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
719
        $out .= "  - id: " . yq($host->{id}) . "\n";
720
        for my $key (qw(status hosts_ip dns_ip)) {
721
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
722
        }
723
        for my $key (qw(names roles sources)) {
724
            $out .= "    $key:\n";
725
            for my $value (@{ $host->{$key} || [] }) {
726
                $out .= "      - " . yq($value) . "\n";
727
            }
728
        }
729
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
730
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
731
    }
732
    return $out;
733
}
734

            
Xdev Host Manager authored a week ago
735
sub parse_work_orders_yaml {
736
    my ($text) = @_;
737
    my %orders = (
738
        version => 1,
739
        work_orders => [],
740
    );
Xdev Host Manager authored a week ago
741
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
742
    for my $line (split /\n/, $text) {
743
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
744
        if ($line =~ /^version:\s*(\d+)/) {
745
            $orders{version} = int($1);
746
        } elsif ($line =~ /^work_orders:\s*$/) {
747
            $section = 'work_orders';
748
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
749
            $current = {
750
                id => yaml_unquote($1),
751
                status => 'pending',
Xdev Host Manager authored a week ago
752
                checklist => [],
Xdev Host Manager authored a week ago
753
                actions => [],
754
            };
755
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
756
            $list_section = '';
Xdev Host Manager authored a week ago
757
            $current_action = undef;
Xdev Host Manager authored a week ago
758
            $current_item = undef;
759
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
760
            $list_section = 'checklist';
761
            $current->{checklist} ||= [];
762
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
763
            $current_item = { id => yaml_unquote($1), status => 'pending' };
764
            push @{ $current->{checklist} }, $current_item;
765
            $current_action = undef;
766
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
767
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
768
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
769
            $list_section = 'actions';
Xdev Host Manager authored a week ago
770
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
771
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
772
            $current_action = { type => yaml_unquote($1) };
773
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
774
            $current_item = undef;
775
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
776
            $current_action->{$1} = yaml_unquote($2);
777
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
778
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
779
            $list_section = '';
Xdev Host Manager authored a week ago
780
            $current_action = undef;
Xdev Host Manager authored a week ago
781
            $current_item = undef;
Xdev Host Manager authored a week ago
782
        }
783
    }
784
    return \%orders;
785
}
786

            
787
sub render_work_orders_yaml {
788
    my ($orders) = @_;
789
    my $out = "version: " . int($orders->{version} || 1) . "\n";
790
    $out .= "work_orders:\n";
791
    for my $wo (@{ $orders->{work_orders} || [] }) {
792
        $out .= "  - id: " . yq($wo->{id}) . "\n";
793
        for my $key (qw(status title reason created_at confirmed_at result)) {
794
            next unless exists $wo->{$key} && length($wo->{$key} || '');
795
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
796
        }
Xdev Host Manager authored a week ago
797
        $out .= "    checklist:\n";
798
        for my $item (@{ $wo->{checklist} || [] }) {
799
            $out .= "      - id: " . yq($item->{id}) . "\n";
800
            for my $key (qw(text status owner notes updated_at)) {
801
                next unless exists $item->{$key} && length($item->{$key} || '');
802
                $out .= "        $key: " . yq($item->{$key}) . "\n";
803
            }
804
        }
Xdev Host Manager authored a week ago
805
        $out .= "    actions:\n";
806
        for my $action (@{ $wo->{actions} || [] }) {
807
            $out .= "      - type: " . yq($action->{type}) . "\n";
808
            for my $key (qw(host_id name)) {
809
                next unless exists $action->{$key} && length($action->{$key} || '');
810
                $out .= "        $key: " . yq($action->{$key}) . "\n";
811
            }
812
        }
813
    }
814
    return $out;
815
}
816

            
Xdev Host Manager authored a week ago
817
sub request_payload {
818
    my ($headers, $body) = @_;
819
    my $type = $headers->{'content-type'} || '';
820
    if ($type =~ m{application/json}) {
821
        return json_decode($body || '{}');
822
    }
823
    return { parse_params($body || '') };
824
}
825

            
826
sub json_bool {
827
    my ($value) = @_;
828
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
829
}
830

            
831
sub json_encode {
832
    my ($value) = @_;
833
    if (!defined $value) {
834
        return 'null';
835
    }
836
    my $ref = ref($value);
837
    if (!$ref) {
838
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
839
        return json_string($value);
840
    }
841
    if ($ref eq 'HostManager::JSONBool') {
842
        return $$value ? 'true' : 'false';
843
    }
844
    if ($ref eq 'ARRAY') {
845
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
846
    }
847
    if ($ref eq 'HASH') {
848
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
849
    }
850
    return json_string("$value");
851
}
852

            
853
sub json_string {
854
    my ($value) = @_;
855
    $value = '' unless defined $value;
856
    $value =~ s/\\/\\\\/g;
857
    $value =~ s/"/\\"/g;
858
    $value =~ s/\n/\\n/g;
859
    $value =~ s/\r/\\r/g;
860
    $value =~ s/\t/\\t/g;
861
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
862
    return qq("$value");
863
}
864

            
865
sub json_decode {
866
    my ($text) = @_;
867
    my $i = 0;
868
    my $len = length($text);
869
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
870

            
871
    $skip_ws = sub {
872
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
873
    };
874

            
875
    $parse_string = sub {
876
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
877
        $i++;
878
        my $out = '';
879
        while ($i < $len) {
880
            my $ch = substr($text, $i++, 1);
881
            return $out if $ch eq '"';
882
            if ($ch eq "\\") {
883
                die "Bad JSON escape\n" if $i >= $len;
884
                my $esc = substr($text, $i++, 1);
885
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
886
                    $out .= $esc;
887
                } elsif ($esc eq 'b') {
888
                    $out .= "\b";
889
                } elsif ($esc eq 'f') {
890
                    $out .= "\f";
891
                } elsif ($esc eq 'n') {
892
                    $out .= "\n";
893
                } elsif ($esc eq 'r') {
894
                    $out .= "\r";
895
                } elsif ($esc eq 't') {
896
                    $out .= "\t";
897
                } elsif ($esc eq 'u') {
898
                    my $hex = substr($text, $i, 4);
899
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
900
                    $out .= chr(hex($hex));
901
                    $i += 4;
902
                } else {
903
                    die "Bad JSON escape\n";
904
                }
905
            } else {
906
                $out .= $ch;
907
            }
908
        }
909
        die "Unterminated JSON string\n";
910
    };
911

            
912
    $parse_number = sub {
913
        my $start = $i;
914
        $i++ if substr($text, $i, 1) eq '-';
915
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
916
        if ($i < $len && substr($text, $i, 1) eq '.') {
917
            $i++;
918
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
919
        }
920
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
921
            $i++;
922
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
923
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
924
        }
925
        return 0 + substr($text, $start, $i - $start);
926
    };
927

            
928
    $parse_array = sub {
929
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
930
        $i++;
931
        my @out;
932
        $skip_ws->();
933
        if ($i < $len && substr($text, $i, 1) eq ']') {
934
            $i++;
935
            return \@out;
936
        }
937
        while (1) {
938
            push @out, $parse_value->();
939
            $skip_ws->();
940
            my $ch = substr($text, $i++, 1);
941
            last if $ch eq ']';
942
            die "Expected JSON array comma\n" unless $ch eq ',';
943
        }
944
        return \@out;
945
    };
946

            
947
    $parse_object = sub {
948
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
949
        $i++;
950
        my %out;
951
        $skip_ws->();
952
        if ($i < $len && substr($text, $i, 1) eq '}') {
953
            $i++;
954
            return \%out;
955
        }
956
        while (1) {
957
            $skip_ws->();
958
            my $key = $parse_string->();
959
            $skip_ws->();
960
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
961
            $out{$key} = $parse_value->();
962
            $skip_ws->();
963
            my $ch = substr($text, $i++, 1);
964
            last if $ch eq '}';
965
            die "Expected JSON object comma\n" unless $ch eq ',';
966
        }
967
        return \%out;
968
    };
969

            
970
    $parse_value = sub {
971
        $skip_ws->();
972
        die "Unexpected end of JSON\n" if $i >= $len;
973
        my $ch = substr($text, $i, 1);
974
        return $parse_string->() if $ch eq '"';
975
        return $parse_object->() if $ch eq '{';
976
        return $parse_array->() if $ch eq '[';
977
        if (substr($text, $i, 4) eq 'true') {
978
            $i += 4;
979
            return json_bool(1);
980
        }
981
        if (substr($text, $i, 5) eq 'false') {
982
            $i += 5;
983
            return json_bool(0);
984
        }
985
        if (substr($text, $i, 4) eq 'null') {
986
            $i += 4;
987
            return undef;
988
        }
989
        return $parse_number->() if $ch =~ /[-0-9]/;
990
        die "Unexpected JSON token\n";
991
    };
992

            
993
    my $value = $parse_value->();
994
    $skip_ws->();
995
    die "Trailing JSON content\n" if $i != $len;
996
    return $value;
997
}
998

            
999
sub parse_params {
1000
    my ($text) = @_;
1001
    my %out;
1002
    for my $pair (split /&/, $text) {
1003
        next unless length $pair;
1004
        my ($k, $v) = split /=/, $pair, 2;
1005
        $out{url_decode($k)} = url_decode($v || '');
1006
    }
1007
    return %out;
1008
}
1009

            
1010
sub clean_id {
1011
    my ($value) = @_;
1012
    $value = lc clean_scalar($value);
1013
    $value =~ s/[^a-z0-9_.-]+/-/g;
1014
    $value =~ s/^-+|-+$//g;
1015
    return $value;
1016
}
1017

            
1018
sub clean_scalar {
1019
    my ($value) = @_;
1020
    $value = '' unless defined $value;
1021
    $value =~ s/[\r\n\t]+/ /g;
1022
    $value =~ s/^\s+|\s+$//g;
1023
    return $value;
1024
}
1025

            
1026
sub clean_list {
1027
    my ($value) = @_;
1028
    return () unless defined $value;
1029
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1030
    my @clean;
1031
    for my $item (@items) {
1032
        $item = clean_scalar($item);
1033
        push @clean, $item if length $item;
1034
    }
1035
    return @clean;
1036
}
1037

            
1038
sub yq {
1039
    my ($value) = @_;
1040
    $value = '' unless defined $value;
1041
    $value =~ s/\\/\\\\/g;
1042
    $value =~ s/"/\\"/g;
1043
    return qq("$value");
1044
}
1045

            
1046
sub yaml_unquote {
1047
    my ($value) = @_;
1048
    $value = '' unless defined $value;
1049
    $value =~ s/^\s+|\s+$//g;
1050
    if ($value =~ /^"(.*)"$/) {
1051
        $value = $1;
1052
        $value =~ s/\\"/"/g;
1053
        $value =~ s/\\\\/\\/g;
1054
    }
1055
    return $value;
1056
}
1057

            
1058
sub verify_totp {
1059
    my ($secret, $otp) = @_;
1060
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1061
    my $key = eval { base32_decode($secret) };
1062
    return 0 if $@ || !length $key;
1063
    my $counter = int(time() / 30);
1064
    for my $offset (-1, 0, 1) {
1065
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1066
    }
1067
    return 0;
1068
}
1069

            
1070
sub totp_code {
1071
    my ($key, $counter) = @_;
1072
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1073
    my $hash = hmac_sha1($msg, $key);
1074
    my $offset = ord(substr($hash, -1)) & 0x0f;
1075
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1076
    return sprintf('%06d', $bin % 1_000_000);
1077
}
1078

            
1079
sub base32_decode {
1080
    my ($text) = @_;
1081
    $text = uc($text || '');
1082
    $text =~ s/[^A-Z2-7]//g;
1083
    my %map;
1084
    my @chars = ('A'..'Z', '2'..'7');
1085
    @map{@chars} = (0..31);
1086
    my ($bits, $value, $out) = (0, 0, '');
1087
    for my $char (split //, $text) {
1088
        die "Invalid base32\n" unless exists $map{$char};
1089
        $value = ($value << 5) | $map{$char};
1090
        $bits += 5;
1091
        while ($bits >= 8) {
1092
            $bits -= 8;
1093
            $out .= chr(($value >> $bits) & 0xff);
1094
        }
1095
    }
1096
    return $out;
1097
}
1098

            
1099
sub create_session {
1100
    my $nonce = random_hex(24);
1101
    my $expires = int(time() + 8 * 3600);
1102
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1103
    my $token = "$nonce:$expires:$sig";
1104
    $sessions{$token} = $expires;
1105
    return $token;
1106
}
1107

            
1108
sub is_authenticated {
1109
    my ($headers) = @_;
1110
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1111
    return 0 unless $token;
1112
    my ($nonce, $expires, $sig) = split /:/, $token;
1113
    return 0 unless $nonce && $expires && $sig;
1114
    return 0 if $expires < time();
1115
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1116
    return exists $sessions{$token};
1117
}
1118

            
1119
sub expire_session {
1120
    my ($headers) = @_;
1121
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1122
    delete $sessions{$token} if $token;
1123
}
1124

            
1125
sub cookie_value {
1126
    my ($cookie, $name) = @_;
1127
    for my $part (split /;\s*/, $cookie) {
1128
        my ($k, $v) = split /=/, $part, 2;
1129
        return $v if defined $k && $k eq $name;
1130
    }
1131
    return '';
1132
}
1133

            
1134
sub send_json {
1135
    my ($client, $status, $payload, $extra_headers) = @_;
1136
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1137
}
1138

            
Xdev Host Manager authored a week ago
1139
sub send_json_raw {
1140
    my ($client, $status, $json_body, $extra_headers) = @_;
1141
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1142
}
1143

            
Xdev Host Manager authored a week ago
1144
sub send_html {
1145
    my ($client, $status, $html) = @_;
1146
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1147
}
1148

            
1149
sub send_text {
1150
    my ($client, $status, $text) = @_;
1151
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1152
}
1153

            
1154
sub send_download {
1155
    my ($client, $status, $content, $type, $filename) = @_;
1156
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1157
}
1158

            
1159
sub send_file {
1160
    my ($client, $path, $type, $filename) = @_;
1161
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1162
    return send_download($client, 200, read_file($path), $type, $filename);
1163
}
1164

            
1165
sub send_response {
1166
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1167
    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
1168
    $body = '' unless defined $body;
1169
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1170
    print $client "Content-Type: $type\r\n";
1171
    print $client "Content-Length: " . length($body) . "\r\n";
1172
    print $client "Cache-Control: no-store\r\n";
1173
    print $client "$_\r\n" for @{ $extra_headers || [] };
1174
    print $client "Connection: close\r\n\r\n";
1175
    print $client $body;
1176
}
1177

            
1178
sub read_file {
1179
    my ($path) = @_;
1180
    open my $fh, '<', $path or die "Cannot read $path: $!";
1181
    local $/;
1182
    return <$fh>;
1183
}
1184

            
1185
sub write_file {
1186
    my ($path, $content) = @_;
1187
    open my $fh, '>', $path or die "Cannot write $path: $!";
1188
    print {$fh} $content;
1189
    close $fh or die "Cannot close $path: $!";
1190
}
1191

            
1192
sub backup_file {
1193
    my ($path) = @_;
1194
    return unless -f $path;
1195
    my $backup_dir = "$project_dir/backups/host-manager";
1196
    make_path($backup_dir) unless -d $backup_dir;
1197
    my $name = $path;
1198
    $name =~ s{.*/}{};
1199
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1200
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1201
}
1202

            
Bogdan Timofte authored 4 days ago
1203
my $db_handle;
Bogdan Timofte authored 4 days ago
1204
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1205

            
1206
sub dbh {
1207
    return $db_handle if $db_handle;
1208
    ensure_parent_dir($opt{db});
1209
    $db_handle = DBI->connect(
1210
        "dbi:SQLite:dbname=$opt{db}",
1211
        '',
1212
        '',
1213
        {
1214
            RaiseError => 1,
1215
            PrintError => 0,
1216
            AutoCommit => 1,
1217
            sqlite_unicode => 1,
1218
        },
1219
    ) or die "Cannot open SQLite database $opt{db}\n";
1220
    $db_handle->do('PRAGMA journal_mode = WAL');
1221
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1222
    create_database_schema($db_handle);
1223
    seed_database($db_handle) unless $db_seeded++;
1224
    return $db_handle;
1225
}
1226

            
1227
sub create_database_schema {
1228
    my ($dbh) = @_;
1229
    $dbh->do(<<'SQL');
1230
CREATE TABLE IF NOT EXISTS schema_meta (
1231
    key TEXT PRIMARY KEY,
1232
    value TEXT NOT NULL,
1233
    updated_at TEXT NOT NULL
1234
)
1235
SQL
1236
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1237
CREATE TABLE IF NOT EXISTS documents (
1238
    name TEXT PRIMARY KEY,
1239
    content TEXT NOT NULL,
1240
    updated_at TEXT NOT NULL
1241
)
1242
SQL
Bogdan Timofte authored 4 days ago
1243
    $dbh->do(
1244
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1245
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1246
        undef, 'schema_version', '2', iso_now()
1247
    );
1248
    $dbh->do(<<'SQL');
1249
CREATE TABLE IF NOT EXISTS hosts (
1250
    fqdn TEXT PRIMARY KEY,
1251
    legacy_id TEXT NOT NULL UNIQUE,
1252
    status TEXT NOT NULL DEFAULT 'active',
1253
    hosts_ip TEXT NOT NULL DEFAULT '',
1254
    dns_ip TEXT NOT NULL DEFAULT '',
1255
    monitoring TEXT NOT NULL DEFAULT 'pending',
1256
    notes TEXT NOT NULL DEFAULT '',
1257
    created_at TEXT NOT NULL,
1258
    updated_at TEXT NOT NULL
1259
)
1260
SQL
1261
    $dbh->do(<<'SQL');
1262
CREATE TABLE IF NOT EXISTS host_aliases (
1263
    alias_name TEXT NOT NULL,
1264
    host_fqdn TEXT NOT NULL,
1265
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1266
    status TEXT NOT NULL DEFAULT 'active',
1267
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1268
    created_at TEXT NOT NULL,
1269
    retired_at TEXT,
1270
    notes TEXT NOT NULL DEFAULT '',
1271
    PRIMARY KEY (alias_name, host_fqdn),
1272
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1273
)
1274
SQL
1275
    $dbh->do(<<'SQL');
1276
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1277
ON host_aliases(alias_name)
1278
WHERE status = 'active'
1279
SQL
1280
    $dbh->do(<<'SQL');
1281
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1282
ON host_aliases(host_fqdn, status)
1283
SQL
1284
    $dbh->do(<<'SQL');
1285
CREATE TABLE IF NOT EXISTS host_roles (
1286
    host_fqdn TEXT NOT NULL,
1287
    role TEXT NOT NULL,
1288
    status TEXT NOT NULL DEFAULT 'active',
1289
    created_at TEXT NOT NULL,
1290
    retired_at TEXT,
1291
    PRIMARY KEY (host_fqdn, role),
1292
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1293
)
1294
SQL
1295
    $dbh->do(<<'SQL');
1296
CREATE TABLE IF NOT EXISTS host_sources (
1297
    host_fqdn TEXT NOT NULL,
1298
    source TEXT NOT NULL,
1299
    status TEXT NOT NULL DEFAULT 'active',
1300
    created_at TEXT NOT NULL,
1301
    retired_at TEXT,
1302
    PRIMARY KEY (host_fqdn, source),
1303
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1304
)
1305
SQL
1306
    $dbh->do(<<'SQL');
1307
CREATE TABLE IF NOT EXISTS host_flags (
1308
    host_fqdn TEXT NOT NULL,
1309
    flag TEXT NOT NULL,
1310
    value TEXT NOT NULL DEFAULT '1',
1311
    created_at TEXT NOT NULL,
1312
    updated_at TEXT NOT NULL,
1313
    PRIMARY KEY (host_fqdn, flag),
1314
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1315
)
1316
SQL
1317
    $dbh->do(<<'SQL');
1318
CREATE TABLE IF NOT EXISTS host_ssh (
1319
    host_fqdn TEXT NOT NULL,
1320
    profile_name TEXT NOT NULL DEFAULT 'default',
1321
    username TEXT NOT NULL DEFAULT '',
1322
    port INTEGER NOT NULL DEFAULT 22,
1323
    identity_file TEXT NOT NULL DEFAULT '',
1324
    address TEXT NOT NULL DEFAULT '',
1325
    local_forward_host TEXT NOT NULL DEFAULT '',
1326
    local_forward_port INTEGER,
1327
    remote_forward_host TEXT NOT NULL DEFAULT '',
1328
    remote_forward_port INTEGER,
1329
    notes TEXT NOT NULL DEFAULT '',
1330
    created_at TEXT NOT NULL,
1331
    updated_at TEXT NOT NULL,
1332
    PRIMARY KEY (host_fqdn, profile_name),
1333
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1334
)
1335
SQL
1336
    $dbh->do(<<'SQL');
1337
CREATE TABLE IF NOT EXISTS certificates (
1338
    certificate_id TEXT PRIMARY KEY,
1339
    host_fqdn TEXT,
1340
    common_name TEXT NOT NULL DEFAULT '',
1341
    subject TEXT NOT NULL DEFAULT '',
1342
    issuer TEXT NOT NULL DEFAULT '',
1343
    serial TEXT UNIQUE,
1344
    status TEXT NOT NULL DEFAULT 'issued',
1345
    not_before TEXT NOT NULL DEFAULT '',
1346
    not_after TEXT NOT NULL DEFAULT '',
1347
    fingerprint_sha256 TEXT UNIQUE,
1348
    cert_path TEXT NOT NULL DEFAULT '',
1349
    csr_path TEXT NOT NULL DEFAULT '',
1350
    created_at TEXT NOT NULL,
1351
    updated_at TEXT NOT NULL,
1352
    notes TEXT NOT NULL DEFAULT '',
1353
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1354
)
1355
SQL
1356
    $dbh->do(<<'SQL');
1357
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1358
    certificate_id TEXT NOT NULL,
1359
    dns_name TEXT NOT NULL,
1360
    PRIMARY KEY (certificate_id, dns_name),
1361
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1362
)
1363
SQL
1364
    $dbh->do(<<'SQL');
1365
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1366
ON certificate_dns_names(dns_name)
1367
SQL
1368
    $dbh->do(<<'SQL');
1369
CREATE TABLE IF NOT EXISTS vhosts (
1370
    vhost_fqdn TEXT PRIMARY KEY,
1371
    host_fqdn TEXT NOT NULL,
1372
    status TEXT NOT NULL DEFAULT 'active',
1373
    service_name TEXT NOT NULL DEFAULT '',
1374
    upstream_url TEXT NOT NULL DEFAULT '',
1375
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1376
    certificate_id TEXT,
1377
    notes TEXT NOT NULL DEFAULT '',
1378
    created_at TEXT NOT NULL,
1379
    updated_at TEXT NOT NULL,
1380
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1381
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1382
)
1383
SQL
1384
    $dbh->do(<<'SQL');
1385
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1386
ON vhosts(host_fqdn, status)
1387
SQL
1388
    $dbh->do(<<'SQL');
1389
CREATE TABLE IF NOT EXISTS data_workers (
1390
    worker_id TEXT PRIMARY KEY,
1391
    worker_type TEXT NOT NULL,
1392
    name TEXT NOT NULL DEFAULT '',
1393
    status TEXT NOT NULL DEFAULT 'active',
1394
    source TEXT NOT NULL DEFAULT '',
1395
    last_run_at TEXT,
1396
    notes TEXT NOT NULL DEFAULT '',
1397
    created_at TEXT NOT NULL,
1398
    updated_at TEXT NOT NULL
1399
)
1400
SQL
1401
    $dbh->do(<<'SQL');
1402
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1403
ON data_workers(worker_type, status)
1404
SQL
1405
    $dbh->do(<<'SQL');
1406
CREATE TABLE IF NOT EXISTS dhcp_leases (
1407
    lease_key TEXT PRIMARY KEY,
1408
    worker_id TEXT NOT NULL,
1409
    host_fqdn TEXT,
1410
    observed_name TEXT NOT NULL DEFAULT '',
1411
    ip_address TEXT NOT NULL,
1412
    mac_address TEXT NOT NULL DEFAULT '',
1413
    lease_state TEXT NOT NULL DEFAULT '',
1414
    first_seen TEXT NOT NULL,
1415
    last_seen TEXT NOT NULL,
1416
    raw TEXT NOT NULL DEFAULT '',
1417
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1418
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1419
)
1420
SQL
1421
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1422
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1423
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1424
    $dbh->do(<<'SQL');
1425
CREATE TABLE IF NOT EXISTS mdns_observations (
1426
    observation_key TEXT PRIMARY KEY,
1427
    worker_id TEXT NOT NULL,
1428
    host_fqdn TEXT,
1429
    observed_name TEXT NOT NULL,
1430
    ip_address TEXT NOT NULL,
1431
    rr_type TEXT NOT NULL DEFAULT 'A',
1432
    ttl INTEGER NOT NULL DEFAULT 0,
1433
    first_seen TEXT NOT NULL,
1434
    last_seen TEXT NOT NULL,
1435
    seen_count INTEGER NOT NULL DEFAULT 1,
1436
    last_peer TEXT NOT NULL DEFAULT '',
1437
    raw TEXT NOT NULL DEFAULT '',
1438
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1439
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1440
)
1441
SQL
1442
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1443
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1444
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1445
    $dbh->do(<<'SQL');
1446
CREATE TABLE IF NOT EXISTS work_orders (
1447
    id TEXT PRIMARY KEY,
1448
    status TEXT NOT NULL DEFAULT 'pending',
1449
    title TEXT NOT NULL DEFAULT '',
1450
    reason TEXT NOT NULL DEFAULT '',
1451
    created_at TEXT NOT NULL,
1452
    confirmed_at TEXT NOT NULL DEFAULT '',
1453
    result TEXT NOT NULL DEFAULT '',
1454
    updated_at TEXT NOT NULL
1455
)
1456
SQL
1457
    $dbh->do(<<'SQL');
1458
CREATE TABLE IF NOT EXISTS work_order_checklist (
1459
    work_order_id TEXT NOT NULL,
1460
    item_id TEXT NOT NULL,
1461
    text TEXT NOT NULL DEFAULT '',
1462
    status TEXT NOT NULL DEFAULT 'pending',
1463
    owner TEXT NOT NULL DEFAULT '',
1464
    notes TEXT NOT NULL DEFAULT '',
1465
    updated_at TEXT NOT NULL DEFAULT '',
1466
    PRIMARY KEY (work_order_id, item_id),
1467
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1468
)
1469
SQL
1470
    $dbh->do(<<'SQL');
1471
CREATE TABLE IF NOT EXISTS work_order_actions (
1472
    work_order_id TEXT NOT NULL,
1473
    position INTEGER NOT NULL,
1474
    type TEXT NOT NULL,
1475
    host_fqdn TEXT,
1476
    host_legacy_id TEXT NOT NULL DEFAULT '',
1477
    name TEXT NOT NULL DEFAULT '',
1478
    payload TEXT NOT NULL DEFAULT '',
1479
    PRIMARY KEY (work_order_id, position),
1480
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1481
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1482
)
1483
SQL
Bogdan Timofte authored 4 days ago
1484
}
1485

            
Bogdan Timofte authored 4 days ago
1486
sub seed_database {
1487
    my ($dbh) = @_;
1488
    seed_default_workers($dbh);
1489

            
1490
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1491
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1492
        normalize_registry_policy($registry);
1493
        with_transaction($dbh, sub {
1494
            import_registry_to_db($dbh, $registry, 0);
1495
        });
1496
    }
1497

            
1498
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1499
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1500
        with_transaction($dbh, sub {
1501
            import_work_orders_to_db($dbh, $orders);
1502
        });
1503
    }
1504

            
1505
    seed_mdns_observations_from_yaml($dbh);
1506
}
1507

            
1508
sub with_transaction {
1509
    my ($dbh, $code) = @_;
1510
    return $code->() unless $dbh->{AutoCommit};
1511
    $dbh->begin_work;
1512
    my $ok = eval {
1513
        $code->();
1514
        1;
1515
    };
1516
    if (!$ok) {
1517
        my $err = $@ || 'transaction failed';
1518
        eval { $dbh->rollback };
1519
        die $err;
1520
    }
1521
    $dbh->commit;
1522
}
1523

            
1524
sub db_scalar {
1525
    my ($dbh, $sql, @bind) = @_;
1526
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1527
    return $value || 0;
1528
}
1529

            
1530
sub legacy_document_text {
1531
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1532
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1533
    return $row->{content} if $row && defined $row->{content};
1534
    return read_file($seed_path) if -f $seed_path;
1535
    return $default_text;
1536
}
1537

            
1538
sub load_registry_from_db {
1539
    my $dbh = dbh();
1540
    my $registry = {
1541
        version => 1,
1542
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1543
        policy => {},
1544
        hosts => [],
1545
    };
Bogdan Timofte authored 4 days ago
1546

            
Bogdan Timofte authored 4 days ago
1547
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1548
    $sth->execute;
1549
    while (my $row = $sth->fetchrow_hashref) {
1550
        my $fqdn = $row->{fqdn};
1551
        push @{ $registry->{hosts} }, {
1552
            id => $row->{legacy_id},
1553
            status => $row->{status},
1554
            hosts_ip => $row->{hosts_ip},
1555
            dns_ip => $row->{dns_ip},
1556
            names => [ active_names_for_host($dbh, $fqdn) ],
1557
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1558
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1559
            monitoring => $row->{monitoring},
1560
            notes => $row->{notes},
1561
        };
1562
    }
1563

            
1564
    return $registry;
Bogdan Timofte authored 4 days ago
1565
}
1566

            
Bogdan Timofte authored 4 days ago
1567
sub save_registry_to_db {
1568
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1569
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1570
    with_transaction($dbh, sub {
1571
        import_registry_to_db($dbh, $registry, 1);
1572
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
1573
    });
1574
}
1575

            
1576
sub import_registry_to_db {
1577
    my ($dbh, $registry, $retire_missing) = @_;
1578
    my %seen;
1579
    for my $host (@{ $registry->{hosts} || [] }) {
1580
        my $fqdn = upsert_host_to_db($dbh, $host);
1581
        $seen{$fqdn} = 1 if $fqdn;
1582
    }
1583

            
1584
    return unless $retire_missing;
1585
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
1586
    $sth->execute('retired');
1587
    while (my ($fqdn) = $sth->fetchrow_array) {
1588
        next if $seen{$fqdn};
1589
        retire_host_in_db($dbh, $fqdn);
1590
    }
1591
}
1592

            
1593
sub upsert_host_to_db {
1594
    my ($dbh, $host) = @_;
1595
    my $now = iso_now();
1596
    my $fqdn = canonical_host_fqdn($host);
1597
    return '' unless $fqdn;
1598
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
1599
    my $status = clean_scalar($host->{status} || 'active');
1600
    my $hosts_ip = clean_scalar($host->{hosts_ip} || '');
1601
    my $dns_ip = clean_scalar($host->{dns_ip} || '');
1602
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
1603
    my $notes = clean_scalar($host->{notes} || '');
1604

            
Bogdan Timofte authored 4 days ago
1605
    $dbh->do(
Bogdan Timofte authored 4 days ago
1606
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
1607
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
1608
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
1609
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
1610
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
1611
        undef,
Bogdan Timofte authored 4 days ago
1612
        $fqdn, $legacy_id, $status, $hosts_ip, $dns_ip, $monitoring, $notes, $now, $now,
1613
    );
1614

            
1615
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
1616
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
1617
    sync_host_names($dbh, $fqdn, [ clean_list($host->{names}) ]);
1618
    return $fqdn;
1619
}
1620

            
1621
sub sync_host_values {
1622
    my ($dbh, $table, $column, $fqdn, $values) = @_;
1623
    my $now = iso_now();
1624
    my %active = map { $_ => 1 } @$values;
1625
    for my $value (@$values) {
1626
        $dbh->do(
1627
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
1628
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
1629
            undef,
1630
            $fqdn, $value, $now,
1631
        );
1632
    }
1633

            
1634
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1635
    $sth->execute($fqdn);
1636
    while (my ($value) = $sth->fetchrow_array) {
1637
        next if $active{$value};
1638
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
1639
    }
1640
}
1641

            
1642
sub sync_host_names {
1643
    my ($dbh, $fqdn, $names) = @_;
1644
    my $now = iso_now();
1645
    my (%aliases, %vhosts);
1646
    if (my $short = short_alias_for_fqdn($fqdn)) {
1647
        $aliases{$short} = 1;
1648
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1649
    }
1650
    for my $name (@$names) {
1651
        $name = normalize_dns_name($name);
1652
        next unless length $name;
1653
        next if $name eq $fqdn;
1654
        if (name_is_vhost($name)) {
1655
            $vhosts{$name} = 1;
1656
            upsert_vhost_to_db($dbh, $fqdn, $name, $now);
1657
            if (my $short = short_alias_for_fqdn($name)) {
1658
                $aliases{$short} = 1;
1659
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
1660
            }
1661
        } else {
1662
            $aliases{$name} = 1;
1663
            upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
1664
            if (my $short = short_alias_for_fqdn($name)) {
1665
                $aliases{$short} = 1;
1666
                upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
1667
            }
1668
        }
1669
    }
1670

            
1671
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
1672
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
1673
}
1674

            
1675
sub upsert_alias_to_db {
1676
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
1677
    $dbh->do(
1678
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
1679
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
1680
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
1681
        undef,
1682
        $alias, $fqdn, $kind, $now,
1683
    );
1684
}
1685

            
1686
sub upsert_vhost_to_db {
1687
    my ($dbh, $fqdn, $vhost, $now) = @_;
1688
    my $service = vhost_service_name($vhost);
1689
    $dbh->do(
1690
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
1691
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
1692
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
1693
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
1694
        undef,
1695
        $vhost, $fqdn, $service, $now, $now,
1696
    );
1697
}
1698

            
1699
sub retire_missing_names {
1700
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
1701
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
1702
    $sth->execute($fqdn);
1703
    while (my ($name) = $sth->fetchrow_array) {
1704
        next if $active->{$name};
1705
        if ($table eq 'host_aliases') {
1706
            $dbh->do(
1707
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
1708
                undef, $now, $fqdn, $name,
1709
            );
1710
        } else {
1711
            $dbh->do(
1712
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
1713
                undef, $now, $fqdn, $name,
1714
            );
1715
        }
1716
    }
1717
}
1718

            
1719
sub retire_host_in_db {
1720
    my ($dbh, $fqdn) = @_;
1721
    my $now = iso_now();
1722
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
1723
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1724
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1725
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1726
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
1727
}
1728

            
1729
sub active_names_for_host {
1730
    my ($dbh, $fqdn) = @_;
1731
    my @names = ($fqdn);
1732
    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");
1733
    $aliases->execute($fqdn);
1734
    while (my ($name) = $aliases->fetchrow_array) {
1735
        push @names, $name;
1736
    }
1737
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
1738
    $vhosts->execute($fqdn);
1739
    while (my ($name) = $vhosts->fetchrow_array) {
1740
        push @names, $name;
1741
    }
1742
    return unique_preserve(@names);
1743
}
1744

            
1745
sub active_values_for_host {
1746
    my ($dbh, $table, $column, $fqdn) = @_;
1747
    my @values;
1748
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
1749
    $sth->execute($fqdn);
1750
    while (my ($value) = $sth->fetchrow_array) {
1751
        push @values, $value;
1752
    }
1753
    return @values;
1754
}
1755

            
1756
sub load_work_orders_from_db {
1757
    my $dbh = dbh();
1758
    my $orders = { version => 1, work_orders => [] };
1759
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
1760
    $sth->execute;
1761
    while (my $row = $sth->fetchrow_hashref) {
1762
        my $wo = {
1763
            id => $row->{id},
1764
            status => $row->{status},
1765
            title => $row->{title},
1766
            reason => $row->{reason},
1767
            created_at => $row->{created_at},
1768
            checklist => [],
1769
            actions => [],
1770
        };
1771
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
1772
        $wo->{result} = $row->{result} if length($row->{result} || '');
1773

            
1774
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
1775
        $items->execute($row->{id});
1776
        while (my $item = $items->fetchrow_hashref) {
1777
            my %copy = (
1778
                id => $item->{item_id},
1779
                text => $item->{text},
1780
                status => $item->{status},
1781
            );
1782
            for my $key (qw(owner notes updated_at)) {
1783
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
1784
            }
1785
            push @{ $wo->{checklist} }, \%copy;
1786
        }
1787

            
1788
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
1789
        $actions->execute($row->{id});
1790
        while (my $action = $actions->fetchrow_hashref) {
1791
            my %copy = ( type => $action->{type} );
1792
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
1793
            $copy{name} = $action->{name} if length($action->{name} || '');
1794
            push @{ $wo->{actions} }, \%copy;
1795
        }
1796

            
1797
        push @{ $orders->{work_orders} }, $wo;
1798
    }
1799
    return $orders;
1800
}
1801

            
1802
sub save_work_orders_to_db {
1803
    my ($orders) = @_;
1804
    my $dbh = dbh();
1805
    with_transaction($dbh, sub {
1806
        import_work_orders_to_db($dbh, $orders);
1807
    });
1808
}
1809

            
1810
sub import_work_orders_to_db {
1811
    my ($dbh, $orders) = @_;
1812
    my $now = iso_now();
1813
    my %seen;
1814
    for my $wo (@{ $orders->{work_orders} || [] }) {
1815
        my $id = clean_scalar($wo->{id} || '');
1816
        next unless $id;
1817
        $seen{$id} = 1;
1818
        $dbh->do(
1819
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
1820
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
1821
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
1822
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
1823
            undef,
1824
            $id,
1825
            clean_scalar($wo->{status} || 'pending'),
1826
            clean_scalar($wo->{title} || ''),
1827
            clean_scalar($wo->{reason} || ''),
1828
            clean_scalar($wo->{created_at} || $now),
1829
            clean_scalar($wo->{confirmed_at} || ''),
1830
            clean_scalar($wo->{result} || ''),
1831
            $now,
1832
        );
1833
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
1834
        for my $item (@{ $wo->{checklist} || [] }) {
1835
            $dbh->do(
1836
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
1837
                undef,
1838
                $id,
1839
                clean_scalar($item->{id} || ''),
1840
                clean_scalar($item->{text} || ''),
1841
                clean_scalar($item->{status} || 'pending'),
1842
                clean_scalar($item->{owner} || ''),
1843
                clean_scalar($item->{notes} || ''),
1844
                clean_scalar($item->{updated_at} || ''),
1845
            );
1846
        }
1847
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
1848
        my $position = 0;
1849
        for my $action (@{ $wo->{actions} || [] }) {
1850
            my $legacy_id = clean_id($action->{host_id} || '');
1851
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
1852
            $dbh->do(
1853
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
1854
                undef,
1855
                $id,
1856
                $position++,
1857
                clean_scalar($action->{type} || ''),
1858
                $host_fqdn || undef,
1859
                $legacy_id,
1860
                normalize_dns_name($action->{name} || ''),
1861
                '',
1862
            );
1863
        }
1864
    }
1865
}
1866

            
1867
sub seed_default_workers {
1868
    my ($dbh) = @_;
1869
    my $now = iso_now();
1870
    my @workers = (
1871
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
1872
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
1873
    );
1874
    for my $worker (@workers) {
1875
        $dbh->do(
1876
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
1877
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
1878
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
1879
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
1880
            undef,
1881
            @$worker,
1882
            $now,
1883
            $now,
1884
        );
1885
    }
1886
}
1887

            
1888
sub seed_mdns_observations_from_yaml {
1889
    my ($dbh) = @_;
1890
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
1891
    my $path = "$project_dir/var/mdns-observations.yaml";
1892
    return unless -f $path;
1893
    my $db = parse_mdns_observations_yaml(read_file($path));
1894
    with_transaction($dbh, sub {
1895
        for my $observation (@{ $db->{observations} || [] }) {
1896
            $dbh->do(
1897
                '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) '
1898
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
1899
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
1900
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
1901
                undef,
1902
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
1903
                clean_scalar($observation->{name} || ''),
1904
                clean_scalar($observation->{ip} || ''),
1905
                int($observation->{ttl} || 0),
1906
                clean_scalar($observation->{first_seen} || iso_now()),
1907
                clean_scalar($observation->{last_seen} || iso_now()),
1908
                int($observation->{seen_count} || 1),
1909
                clean_scalar($observation->{last_peer} || ''),
1910
            );
1911
        }
1912
    });
1913
}
1914

            
1915
sub parse_mdns_observations_yaml {
1916
    my ($text) = @_;
1917
    my %db = ( observations => [] );
1918
    my ($section, $current);
1919
    for my $line (split /\n/, $text || '') {
1920
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1921
        if ($line =~ /^observations:\s*$/) {
1922
            $section = 'observations';
1923
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
1924
            $current = { key => yaml_unquote($1) };
1925
            push @{ $db{observations} }, $current;
1926
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1927
            $current->{$1} = yaml_unquote($2);
1928
        }
1929
    }
1930
    return \%db;
1931
}
1932

            
1933
sub set_schema_meta {
1934
    my ($dbh, $key, $value) = @_;
1935
    $dbh->do(
1936
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1937
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1938
        undef,
1939
        $key,
1940
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
1941
        iso_now(),
1942
    );
1943
}
1944

            
Bogdan Timofte authored 4 days ago
1945
sub fqdn_for_legacy_id {
1946
    my ($dbh, $legacy_id) = @_;
1947
    return '' unless length($legacy_id || '');
1948
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
1949
    return $fqdn || '';
1950
}
1951

            
1952
sub canonical_host_fqdn {
1953
    my ($host) = @_;
1954
    my @names = map { normalize_dns_name($_) } @{ $host->{names} || [] };
1955
    for my $name (@names) {
1956
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
1957
    }
1958
    for my $name (@names) {
1959
        return $name if $name =~ /\./ && !name_is_vhost($name);
1960
    }
1961
    for my $name (@names) {
1962
        return $name if $name =~ /\./;
1963
    }
1964
    my $id = clean_id($host->{id} || '');
1965
    return $id ? "$id.madagascar.xdev.ro" : '';
1966
}
1967

            
1968
sub legacy_id_from_fqdn {
1969
    my ($fqdn) = @_;
1970
    $fqdn = normalize_dns_name($fqdn);
1971
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
1972
    $fqdn =~ s/\..*\z//;
1973
    return clean_id($fqdn);
1974
}
1975

            
1976
sub normalize_dns_name {
1977
    my ($name) = @_;
1978
    $name = lc clean_scalar($name || '');
1979
    $name =~ s/\.\z//;
1980
    return $name;
1981
}
1982

            
1983
sub name_is_vhost {
1984
    my ($name) = @_;
1985
    $name = normalize_dns_name($name);
1986
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
1987
}
1988

            
1989
sub vhost_service_name {
1990
    my ($name) = @_;
1991
    $name = normalize_dns_name($name);
1992
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
1993
    return '';
1994
}
1995

            
1996
sub short_alias_for_fqdn {
1997
    my ($name) = @_;
1998
    $name = normalize_dns_name($name);
1999
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2000
    return '';
2001
}
2002

            
Bogdan Timofte authored 4 days ago
2003
sub normalize_registry_policy {
2004
    my ($registry) = @_;
2005
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2006
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2007
    $registry->{policy}{runtime_database} = $opt{db};
2008
}
2009

            
2010
sub default_hosts_yaml {
2011
    return <<'YAML';
2012
version: 1
2013
updated_at: ""
2014
policy:
Bogdan Timofte authored 4 days ago
2015
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2016
hosts:
2017
YAML
2018
}
2019

            
2020
sub default_work_orders_yaml {
2021
    return <<'YAML';
2022
version: 1
2023
work_orders:
2024
YAML
2025
}
2026

            
2027
sub ensure_parent_dir {
2028
    my ($path) = @_;
2029
    my $dir = dirname($path);
2030
    make_path($dir) unless -d $dir;
2031
}
2032

            
Xdev Host Manager authored a week ago
2033
sub url_decode {
2034
    my ($value) = @_;
2035
    $value = '' unless defined $value;
2036
    $value =~ tr/+/ /;
2037
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2038
    return $value;
2039
}
2040

            
2041
sub random_hex {
2042
    my ($bytes) = @_;
2043
    if (open my $fh, '<:raw', '/dev/urandom') {
2044
        read($fh, my $raw, $bytes);
2045
        close $fh;
2046
        return unpack('H*', $raw);
2047
    }
2048
    return sha256_hex(rand() . time() . $$);
2049
}
2050

            
2051
sub iso_now {
2052
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2053
}
2054

            
Bogdan Timofte authored 6 days ago
2055
sub build_info {
2056
    my %info = (
2057
        revision => '',
2058
        branch => '',
2059
        built_at => '',
2060
        deployed_at => '',
2061
        dirty => '',
2062
    );
2063

            
2064
    if ($ENV{HOST_MANAGER_BUILD}) {
2065
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2066
        return \%info;
2067
    }
2068

            
2069
    my $build_file = "$project_dir/BUILD";
2070
    if (-f $build_file) {
2071
        for my $line (split /\n/, read_file($build_file)) {
2072
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2073
            $info{$1} = clean_scalar($2);
2074
        }
2075
        return \%info if $info{revision} || $info{built_at};
2076
    }
2077

            
2078
    my $revision = git_value('rev-parse --short=12 HEAD');
2079
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2080
    $info{revision} = $revision if $revision;
2081
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2082
    return \%info;
2083
}
2084

            
2085
sub git_value {
2086
    my ($args) = @_;
2087
    return '' unless -d "$project_dir/.git";
2088
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2089
    my $value = <$fh> || '';
2090
    close $fh;
2091
    chomp $value;
2092
    return clean_scalar($value);
2093
}
2094

            
2095
sub build_label {
2096
    my $info = build_info();
2097
    my $revision = $info->{revision} || 'unknown';
2098
    my $branch = $info->{branch} || '';
2099
    $branch = '' if $branch eq 'HEAD';
2100
    my $label = $branch ? "$branch $revision" : $revision;
2101
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2102
    return $label;
2103
}
2104

            
2105
sub build_title {
2106
    my $info = build_info();
2107
    my $label = build_label();
2108
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2109
    return $stamp ? "$label deployed $stamp" : $label;
2110
}
2111

            
Bogdan Timofte authored 4 days ago
2112
sub build_revision {
2113
    my $info = build_info();
2114
    return $info->{revision} || 'unknown';
2115
}
2116

            
2117
sub build_details {
2118
    my $info = build_info();
2119
    my %details = (
2120
        app => 'Madagascar Local Authority',
2121
        revision => $info->{revision} || 'unknown',
2122
        branch => $info->{branch} || '',
2123
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2124
        built_at => $info->{built_at} || '',
2125
        deployed_at => $info->{deployed_at} || '',
2126
        label => build_label(),
2127
        title => build_title(),
2128
    );
2129
    return json_encode(\%details);
2130
}
2131

            
Bogdan Timofte authored 6 days ago
2132
sub html_escape {
2133
    my ($value) = @_;
2134
    $value = '' unless defined $value;
2135
    $value =~ s/&/&amp;/g;
2136
    $value =~ s/</&lt;/g;
2137
    $value =~ s/>/&gt;/g;
2138
    $value =~ s/"/&quot;/g;
2139
    $value =~ s/'/&#039;/g;
2140
    return $value;
2141
}
2142

            
Xdev Host Manager authored a week ago
2143
sub app_html {
Bogdan Timofte authored 4 days ago
2144
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2145
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2146
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2147
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2148
<!doctype html>
2149
<html lang="ro">
2150
<head>
2151
  <meta charset="utf-8">
2152
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2153
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2154
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2155
  <style>
2156
    :root {
2157
      color-scheme: light;
2158
      --ink: #152033;
2159
      --muted: #647084;
2160
      --line: #d8dee8;
2161
      --soft: #f4f6f9;
2162
      --panel: #ffffff;
2163
      --accent: #1267d8;
2164
      --bad: #b42318;
2165
      --warn: #946200;
2166
      --ok: #137333;
2167
    }
2168
    * { box-sizing: border-box; }
2169
    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
2170

            
2171
    /* ── Login screen ── */
2172
    #login-screen {
2173
      display: flex;
Xdev Host Manager authored a week ago
2174
      align-items: flex-start;
Xdev Host Manager authored a week ago
2175
      justify-content: center;
2176
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2177
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2178
      background: #13182a;
Xdev Host Manager authored a week ago
2179
      overflow: auto;
Xdev Host Manager authored a week ago
2180
    }
2181
    .login-card {
Xdev Host Manager authored a week ago
2182
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2183
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2184
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2185
      background: #fff;
2186
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2187
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2188
         below the first box, sits inside the card instead of spilling past it. */
2189
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2190
      width: 100%;
Xdev Host Manager authored a week ago
2191
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2192
      min-height: 360px;
Xdev Host Manager authored a week ago
2193
      display: grid;
Xdev Host Manager authored a week ago
2194
      align-content: start;
2195
      justify-items: center;
2196
      gap: 28px;
Xdev Host Manager authored a week ago
2197
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2198
    }
Xdev Host Manager authored a week ago
2199
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2200
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2201
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2202
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2203
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2204
    }
Xdev Host Manager authored a week ago
2205
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2206
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2207
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2208
    .login-card form {
2209
      display: grid;
2210
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2211
      justify-self: center;
Bogdan Timofte authored a week ago
2212
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2213
    }
Xdev Host Manager authored a week ago
2214
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2215
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2216
       giving the password manager a username anchor and an aggregated OTP target
2217
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2218
    .pm-helper-fields {
2219
      position: absolute;
2220
      left: -10000px;
2221
      top: auto;
2222
      width: 1px;
2223
      height: 1px;
2224
      overflow: hidden;
2225
      opacity: 0.01;
2226
    }
2227
    .pm-helper-fields input {
2228
      width: 1px;
2229
      height: 1px;
2230
      padding: 0;
2231
      border: 0;
2232
    }
Bogdan Timofte authored 4 days ago
2233
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2234
       hint was what made Safari mark the whole group and re-present its OTP
2235
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2236
    .otp-row {
2237
      display: flex;
2238
      gap: var(--otp-gap);
2239
      justify-content: center;
2240
    }
Bogdan Timofte authored 5 days ago
2241
    .otp-row input {
Xdev Host Manager authored a week ago
2242
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
2243
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2244
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2245
      transition: border-color .15s, background .15s;
2246
    }
Bogdan Timofte authored 5 days ago
2247
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2248
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2249
    #login-error {
2250
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2251
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2252
    }
2253
    @media (max-width: 760px) {
2254
      .login-card {
Xdev Host Manager authored a week ago
2255
        max-width: 520px;
Xdev Host Manager authored a week ago
2256
        min-height: 0;
Bogdan Timofte authored 4 days ago
2257
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2258
        gap: 26px;
2259
      }
2260
      .login-card .brand h1 { font-size: 24px; }
2261
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2262
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2263
    }
Xdev Host Manager authored a week ago
2264
    @media (max-width: 430px) {
2265
      #login-screen { padding: 24px 16px 120px; }
2266
      .login-card {
2267
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2268
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2269
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2270
      }
Bogdan Timofte authored 5 days ago
2271
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2272
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2273
    }
2274
    @media (max-height: 720px) {
2275
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2276
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2277
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2278
    }
Xdev Host Manager authored a week ago
2279

            
2280
    /* ── App shell (hidden until authenticated) ── */
2281
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2282
    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
2283
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2284
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2285
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2286
    nav a:hover { color: var(--ink); background: var(--soft); }
2287
    nav a.active { color: var(--accent); background: #e8f0fe; }
2288
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2289
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2290
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2291
    .page { display: grid; gap: 16px; }
2292
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2293
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2294
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2295
    .panel { overflow: hidden; }
2296
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2297
    .panel-head h2 { margin: 0; font-size: 14px; }
2298
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2299
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2300
    button, input, select, textarea { font: inherit; }
2301
    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; }
2302
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2303
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2304
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2305
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2306
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2307
    textarea { min-height: 74px; resize: vertical; }
2308
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2309
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2310
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2311
    tr:hover td { background: #f8fafc; }
2312
    .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; }
2313
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2314
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2315
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
2316
    .pill.derived { border-style: dashed; }
Xdev Host Manager authored a week ago
2317
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2318
    .span2 { grid-column: 1 / -1; }
2319
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2320
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2321
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2322
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2323
    .ca-fingerprint { overflow-wrap: anywhere; }
2324
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2325
    .build-control {
Bogdan Timofte authored 6 days ago
2326
      position: fixed;
2327
      right: 10px;
2328
      bottom: 8px;
2329
      z-index: 5;
Bogdan Timofte authored 4 days ago
2330
      display: inline-flex;
2331
      align-items: center;
2332
      gap: 4px;
2333
    }
2334
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2335
      color: rgba(255,255,255,.46);
2336
      background: rgba(19,24,42,.28);
2337
      border: 1px solid rgba(255,255,255,.08);
2338
      border-radius: 4px;
2339
      font-size: 10px;
2340
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2341
    }
2342
    .build-badge {
2343
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
2344
      cursor: text;
2345
      user-select: text;
Bogdan Timofte authored 6 days ago
2346
    }
Bogdan Timofte authored 4 days ago
2347
    .build-copy {
2348
      min-height: 0;
2349
      padding: 2px 5px;
2350
      cursor: pointer;
2351
    }
2352
    .build-copy:hover {
2353
      color: rgba(255,255,255,.72);
2354
      border-color: rgba(255,255,255,.24);
2355
    }
2356
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2357
      color: rgba(100,112,132,.58);
2358
      background: rgba(255,255,255,.72);
2359
      border-color: rgba(216,222,232,.72);
2360
    }
Bogdan Timofte authored 4 days ago
2361
    body.is-app .build-copy:hover {
2362
      color: rgba(21,32,51,.78);
2363
      border-color: rgba(100,112,132,.42);
2364
    }
Xdev Host Manager authored a week ago
2365
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2366
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2367
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2368
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2369
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2370
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2371
    .work-order-actions { gap: 4px; }
2372
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2373
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2374
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 5 days ago
2375
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2376
    .host-tools input { max-width: 240px; }
2377
    .modal-backdrop {
2378
      position: fixed;
2379
      inset: 0;
2380
      z-index: 10;
2381
      display: grid;
2382
      align-items: start;
2383
      justify-items: center;
2384
      padding: 72px 16px 24px;
2385
      background: rgba(21,32,51,.48);
2386
      overflow: auto;
2387
    }
2388
    .modal-backdrop[hidden] { display: none; }
2389
    .modal {
2390
      width: min(840px, 100%);
2391
      max-height: calc(100dvh - 96px);
2392
      overflow: auto;
2393
      background: var(--panel);
2394
      border: 1px solid var(--line);
2395
      border-radius: 8px;
2396
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2397
    }
2398
    .modal-head {
2399
      position: sticky;
2400
      top: 0;
2401
      z-index: 1;
2402
      display: flex;
2403
      align-items: center;
2404
      justify-content: space-between;
2405
      gap: 12px;
2406
      padding: 12px 14px;
2407
      border-bottom: 1px solid var(--line);
2408
      background: #fafbfc;
2409
    }
2410
    .modal-head h2 { margin: 0; font-size: 14px; }
2411
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2412
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2413
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2414
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2415
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2416
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2417
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2418
      #message { max-width: 100%; }
2419
      .panel-head { align-items: stretch; flex-direction: column; }
2420
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2421
      .host-tools input { max-width: none; }
2422
      .modal-backdrop { padding-top: 16px; }
2423
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2424
      .grid { grid-template-columns: 1fr; }
2425
      table { min-width: 760px; }
2426
      .table-wrap { overflow-x: auto; }
2427
    }
2428
  </style>
2429
</head>
Bogdan Timofte authored 6 days ago
2430
<body class="is-login">
Xdev Host Manager authored a week ago
2431

            
Xdev Host Manager authored a week ago
2432
  <!-- ── Login screen ── -->
2433
  <div id="login-screen">
2434
    <div class="login-card">
2435
      <div class="brand">
2436
        <div class="icon">
Xdev Host Manager authored a week ago
2437
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2438
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2439
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2440
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2441
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2442
            <path d="M26 20h8M26 32h8M26 44h8"/>
2443
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2444
          </svg>
2445
        </div>
Xdev Host Manager authored a week ago
2446
        <h1>Madagascar Local Authority</h1>
2447
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2448
      </div>
Bogdan Timofte authored 4 days ago
2449
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2450
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2451
        <div class="pm-helper-fields" aria-hidden="true">
2452
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2453
          <input type="hidden" id="otp-hidden" name="otp">
2454
        </div>
Xdev Host Manager authored a week ago
2455
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2456
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2457
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2458
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2459
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2460
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2461
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2462
        </div>
2463
      </form>
2464
    </div>
2465
  </div>
2466

            
2467
  <!-- ── App (shown after login) ── -->
2468
  <div id="app">
2469
    <header>
Xdev Host Manager authored a week ago
2470
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2471
      <nav aria-label="Sections">
2472
        <a href="/overview" data-page-link="overview">Overview</a>
2473
        <a href="/hosts" data-page-link="hosts">Hosts</a>
2474
        <a href="/dns" data-page-link="dns">DNS</a>
2475
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2476
        <a href="/ca" data-page-link="ca">Local CA</a>
2477
      </nav>
Xdev Host Manager authored a week ago
2478
      <div class="header-right">
2479
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2480
        <span id="message" class="muted"></span>
2481
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2482
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2483
      </div>
Xdev Host Manager authored a week ago
2484
    </header>
2485
    <main>
Bogdan Timofte authored 5 days ago
2486
      <section class="page" id="page-overview" data-page="overview">
2487
        <section class="panel">
2488
          <div class="panel-head">
2489
            <h2>Overview</h2>
2490
            <div class="stats" id="stats"></div>
2491
          </div>
2492
          <div class="problems" id="problems"></div>
2493
        </section>
Xdev Host Manager authored a week ago
2494
      </section>
2495

            
Bogdan Timofte authored 5 days ago
2496
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2497
        <section class="panel">
2498
          <div class="panel-head">
2499
            <h2>Hosts</h2>
2500
            <div class="host-tools">
2501
              <input id="filter" placeholder="filter">
2502
              <button type="button" id="new-host">New host</button>
2503
            </div>
2504
          </div>
2505
          <div class="table-wrap">
2506
            <table>
2507
              <thead>
2508
                <tr>
2509
                  <th style="width: 120px">ID</th>
2510
                  <th style="width: 130px">hosts_ip</th>
2511
                  <th style="width: 130px">dns_ip</th>
2512
                  <th>Names</th>
2513
                  <th style="width: 150px">Roles</th>
2514
                  <th style="width: 110px">Monitoring</th>
2515
                  <th style="width: 90px">Status</th>
2516
                </tr>
2517
              </thead>
2518
              <tbody id="hosts"></tbody>
2519
            </table>
2520
          </div>
2521
        </section>
Xdev Host Manager authored a week ago
2522
      </section>
Xdev Host Manager authored a week ago
2523

            
Bogdan Timofte authored 5 days ago
2524
      <section class="page" id="page-dns" data-page="dns" hidden>
2525
        <section class="toolbar">
2526
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
2527
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
2528
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
2529
          <button id="write-tsv">Write local-hosts.tsv</button>
2530
        </section>
Xdev Host Manager authored a week ago
2531
      </section>
2532

            
Bogdan Timofte authored 5 days ago
2533
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
2534
        <section class="panel">
2535
          <div class="panel-head">
2536
            <h2>Work Orders</h2>
2537
            <div class="stats" id="wo-stats"></div>
2538
          </div>
2539
          <div class="problems" id="work-orders"></div>
2540
        </section>
Xdev Host Manager authored a week ago
2541
      </section>
2542

            
Bogdan Timofte authored 5 days ago
2543
      <section class="page" id="page-ca" data-page="ca" hidden>
2544
        <section class="panel">
2545
          <div class="panel-head">
2546
            <h2>Local Certificate Authority</h2>
2547
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
2548
          </div>
2549
          <div class="problems" id="ca-status"></div>
2550
        </section>
2551
        <section class="panel">
2552
          <div class="panel-head">
2553
            <h2>Issued Certificates</h2>
2554
            <div class="stats" id="ca-certs-summary"></div>
2555
          </div>
2556
          <div class="table-wrap">
2557
            <table>
2558
              <thead>
2559
                <tr>
2560
                  <th style="width: 150px">Name</th>
2561
                  <th>DNS names</th>
2562
                  <th style="width: 210px">Validity</th>
2563
                  <th style="width: 180px">Serial</th>
2564
                  <th>Fingerprint</th>
2565
                  <th style="width: 110px">Download</th>
2566
                </tr>
2567
              </thead>
2568
              <tbody id="ca-certs"></tbody>
2569
            </table>
2570
          </div>
2571
        </section>
Xdev Host Manager authored a week ago
2572
      </section>
Bogdan Timofte authored 5 days ago
2573
    </main>
Xdev Host Manager authored a week ago
2574

            
Bogdan Timofte authored 5 days ago
2575
    <div id="host-modal" class="modal-backdrop" hidden>
2576
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
2577
        <div class="modal-head">
2578
          <h2 id="host-modal-title">Edit host</h2>
2579
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
2580
        </div>
2581
        <form id="host-form" class="grid">
2582
          <label>ID<input name="id" required></label>
2583
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2584
          <label>hosts_ip<input name="hosts_ip" required></label>
2585
          <label>dns_ip<input name="dns_ip" required></label>
2586
          <label class="span2">Names<textarea name="names" required></textarea></label>
2587
          <label>Roles<input name="roles"></label>
2588
          <label>Sources<input name="sources"></label>
2589
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2590
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
2591
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
2592
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
2593
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
2594
            <button class="danger" type="button" id="delete-host">Delete host</button>
2595
          </div>
2596
        </form>
2597
      </section>
Bogdan Timofte authored 5 days ago
2598
    </div>
Xdev Host Manager authored a week ago
2599
  </div>
2600

            
Bogdan Timofte authored 4 days ago
2601
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
2602
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
2603
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
2604
  </div>
Bogdan Timofte authored 6 days ago
2605

            
Xdev Host Manager authored a week ago
2606
  <script>
Xdev Host Manager authored a week ago
2607
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
Bogdan Timofte authored 5 days ago
2608
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
2609

            
2610
    const $ = (id) => document.getElementById(id);
2611
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
2612
    const PAGE_PATHS = {
2613
      '/': 'overview',
2614
      '/overview': 'overview',
2615
      '/hosts': 'hosts',
2616
      '/dns': 'dns',
2617
      '/work-orders': 'work-orders',
2618
      '/ca': 'ca',
2619
    };
Xdev Host Manager authored a week ago
2620

            
2621
    async function api(path, options = {}) {
2622
      const res = await fetch(path, options);
2623
      const body = await res.json();
2624
      if (!res.ok) throw new Error(body.error || res.statusText);
2625
      return body;
2626
    }
2627

            
Bogdan Timofte authored 5 days ago
2628
    function currentPage() {
2629
      return PAGE_PATHS[window.location.pathname] || 'overview';
2630
    }
2631

            
2632
    function showPage(page, push = false) {
2633
      const target = page || 'overview';
2634
      document.querySelectorAll('[data-page]').forEach(section => {
2635
        section.hidden = section.dataset.page !== target;
2636
      });
2637
      document.querySelectorAll('[data-page-link]').forEach(link => {
2638
        link.classList.toggle('active', link.dataset.pageLink === target);
2639
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
2640
      });
2641
      if (push) {
2642
        const href = target === 'overview' ? '/overview' : '/' + target;
2643
        history.pushState({ page: target }, '', href);
2644
      }
2645
    }
2646

            
Xdev Host Manager authored a week ago
2647
    function showLogin(errorText) {
Bogdan Timofte authored 6 days ago
2648
      document.body.classList.remove('is-app');
2649
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
2650
      $('app').style.display = 'none';
2651
      $('login-screen').style.display = 'flex';
2652
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
2653
      clearOtp();
Xdev Host Manager authored a week ago
2654
    }
2655

            
2656
    function showApp() {
Bogdan Timofte authored 6 days ago
2657
      document.body.classList.remove('is-login');
2658
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
2659
      $('login-screen').style.display = 'none';
2660
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
2661
      showPage(currentPage());
Xdev Host Manager authored a week ago
2662
    }
2663

            
Xdev Host Manager authored a week ago
2664
    async function refresh() {
2665
      const session = await api('/api/session');
2666
      state.authenticated = session.authenticated;
Xdev Host Manager authored a week ago
2667
      if (!state.authenticated) { showLogin(); return; }
2668
      showApp();
Xdev Host Manager authored a week ago
2669
      const data = await api('/api/hosts');
2670
      state.hosts = data.hosts || [];
2671
      state.problems = data.problems || [];
2672
      render(data);
Xdev Host Manager authored a week ago
2673
      await renderCa();
Xdev Host Manager authored a week ago
2674
      await renderWorkOrders();
Xdev Host Manager authored a week ago
2675
    }
2676

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

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

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

            
2689
      renderHosts();
2690
    }
2691

            
Xdev Host Manager authored a week ago
2692
    async function renderCa() {
2693
      try {
2694
        const status = await api('/api/ca/status');
2695
        if (!status.initialized) {
2696
          $('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
2697
          $('ca-certs-summary').innerHTML = '';
2698
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
2699
          return;
2700
        }
2701
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
2702
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
2703
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
2704
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
2705
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
2706
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
2707
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
2708
            <div>
2709
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
2710
              <span>${certs.length} issued certificate(s)</span>
2711
            </div>
Xdev Host Manager authored a week ago
2712
          </div>`;
Bogdan Timofte authored 5 days ago
2713
        $('ca-certs-summary').innerHTML = [
2714
          ['issued', certs.length],
2715
          ['expiring', certs.filter(cert => {
2716
            const days = daysUntil(cert.not_after);
2717
            return days !== null && days >= 0 && days <= 30;
2718
          }).length],
2719
          ['expired', certs.filter(cert => {
2720
            const days = daysUntil(cert.not_after);
2721
            return days !== null && days < 0;
2722
          }).length],
2723
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2724
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
2725
          const days = daysUntil(cert.not_after);
2726
          const dnsNames = cert.dns_names || [];
2727
          const dnsHtml = dnsNames.length
2728
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
2729
            : '<span class="muted">No DNS SANs reported.</span>';
2730
          return `<tr>
2731
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
2732
            <td>${dnsHtml}</td>
2733
            <td>
2734
              <div class="ca-detail">
2735
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
2736
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
2737
              </div>
2738
            </td>
2739
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
2740
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
2741
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
2742
          </tr>`;
2743
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
2744
      } catch (e) {
2745
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
2746
        $('ca-certs-summary').innerHTML = '';
2747
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
2748
      }
2749
    }
2750

            
Bogdan Timofte authored 5 days ago
2751
    function daysUntil(dateText) {
2752
      const time = Date.parse(dateText || '');
2753
      if (!Number.isFinite(time)) return null;
2754
      return Math.ceil((time - Date.now()) / 86400000);
2755
    }
2756

            
2757
    function certStatusClass(days) {
2758
      if (days === null) return '';
2759
      if (days < 0) return 'bad';
2760
      if (days <= 30) return 'warn';
2761
      return 'ok';
2762
    }
2763

            
2764
    function certStatusLabel(days) {
2765
      if (days === null) return 'validity unknown';
2766
      if (days < 0) return 'expired';
2767
      if (days === 0) return 'expires today';
2768
      return `${days}d remaining`;
2769
    }
2770

            
Xdev Host Manager authored a week ago
2771
    async function renderWorkOrders() {
2772
      try {
2773
        const data = await api('/api/work-orders');
2774
        state.workOrders = data.work_orders || [];
2775
        $('wo-stats').innerHTML = [
2776
          ['pending', data.counts.pending],
2777
          ['total', data.counts.work_orders],
2778
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
2779

            
2780
        if (!state.workOrders.length) {
2781
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
2782
          return;
2783
        }
2784

            
2785
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
2786
          const checklist = wo.checklist || [];
2787
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
2788
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
2789
          const checklistHtml = checklist.map(item => {
2790
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
2791
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
2792
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
2793
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
2794
            </label>`;
2795
          }).join('');
Xdev Host Manager authored a week ago
2796
          const actions = (wo.actions || []).map(a => {
2797
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
2798
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
2799
          }).join('');
2800
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
2801
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
2802
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
2803
            : '';
Bogdan Timofte authored 6 days ago
2804
          return `<div class="problem work-order-card">
2805
            <div class="work-order-head">
Xdev Host Manager authored a week ago
2806
              <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
2807
              ${button}
2808
            </div>
Bogdan Timofte authored 6 days ago
2809
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
2810
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
2811
            <div class="work-order-checklist">${checklistHtml}</div>
2812
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
2813
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
2814
          </div>`;
2815
        }).join('');
Xdev Host Manager authored a week ago
2816
        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
2817
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
2818
      } catch (e) {
2819
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
2820
      }
2821
    }
2822

            
Xdev Host Manager authored a week ago
2823
    async function updateWorkOrderChecklist(id, itemId, checked) {
2824
      try {
2825
        await api('/api/work-orders/checklist', {
2826
          method: 'POST',
2827
          headers: { 'Content-Type': 'application/json' },
2828
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
2829
        });
2830
        msg('work order updated');
2831
        await refresh();
2832
      } catch (e) { msg(e.message); await refresh(); }
2833
    }
2834

            
Xdev Host Manager authored a week ago
2835
    async function confirmWorkOrder(id) {
2836
      const typed = prompt(`Type ${id} to confirm this work order`);
2837
      if (typed !== id) return;
2838
      try {
2839
        await api('/api/work-orders/confirm', {
2840
          method: 'POST',
2841
          headers: { 'Content-Type': 'application/json' },
2842
          body: JSON.stringify({ id, confirm: typed })
2843
        });
2844
        msg('work order confirmed; local-hosts.tsv written');
2845
        await refresh();
2846
      } catch (e) { msg(e.message); }
2847
    }
2848

            
Xdev Host Manager authored a week ago
2849
    function renderHosts() {
2850
      const filter = $('filter').value.toLowerCase();
2851
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
2852
        .slice()
2853
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
2854
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
2855
        .map(h => {
2856
          const problems = state.problems.filter(p => p.host_id === h.id);
2857
          const cls = problems.length ? 'warn' : 'ok';
2858
          return `<tr data-id="${escapeHtml(h.id)}">
2859
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
2860
            <td>${escapeHtml(h.hosts_ip || '')}</td>
2861
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 5 days ago
2862
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
2863
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
2864
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
2865
            <td>${escapeHtml(h.status || '')}</td>
2866
          </tr>`;
2867
        }).join('');
2868
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
2869
    }
2870

            
Bogdan Timofte authored 5 days ago
2871
    function renderNamePills(host) {
2872
      const declared = host.declared_names || host.names || [];
2873
      const derived = host.derived_names || [];
2874
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
2875
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
2876
      return declaredHtml + derivedHtml;
2877
    }
2878

            
Xdev Host Manager authored a week ago
2879
    function editHost(id) {
2880
      const host = state.hosts.find(h => h.id === id);
2881
      if (!host) return;
2882
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
2883
      clearHostFormMessage();
2884
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 5 days ago
2885
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 5 days ago
2886
      hostField('roles').value = (host.roles || []).join(' ');
2887
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
2888
      openHostModal('Edit host');
2889
    }
2890

            
2891
    function newHost() {
2892
      const form = $('host-form');
2893
      form.reset();
Bogdan Timofte authored 5 days ago
2894
      clearHostFormMessage();
2895
      hostField('status').value = 'active';
2896
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
2897
      openHostModal('New host');
2898
    }
2899

            
2900
    function openHostModal(title) {
2901
      $('host-modal-title').textContent = title || 'Edit host';
2902
      $('host-modal').hidden = false;
2903
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
2904
      hostFormSnapshot = hostFormState();
2905
      hostField('id').focus();
2906
    }
2907

            
2908
    function requestCloseHostModal() {
2909
      if ($('save-host').disabled) return;
2910
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
2911
      closeHostModal();
Bogdan Timofte authored 5 days ago
2912
    }
2913

            
2914
    function closeHostModal() {
2915
      $('host-modal').hidden = true;
2916
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
2917
      setHostFormBusy(false);
2918
      clearHostFormMessage();
2919
      hostFormSnapshot = '';
2920
    }
2921

            
2922
    function hostField(name) {
2923
      return $('host-form').elements.namedItem(name);
2924
    }
2925

            
2926
    function hostFormState() {
2927
      return JSON.stringify(formObject($('host-form')));
2928
    }
2929

            
2930
    function hostFormDirty() {
2931
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
2932
    }
2933

            
2934
    function setHostFormBusy(busy) {
2935
      $('save-host').disabled = busy;
2936
      $('delete-host').disabled = busy;
2937
      $('close-host-modal').disabled = busy;
2938
    }
2939

            
2940
    function setHostFormMessage(text, isError = false) {
2941
      const message = $('host-form-message');
2942
      message.textContent = text || '';
2943
      message.classList.toggle('error', !!isError);
2944
    }
2945

            
2946
    function clearHostFormMessage() {
2947
      setHostFormMessage('');
Xdev Host Manager authored a week ago
2948
    }
2949

            
2950
    function formObject(form) {
2951
      return Object.fromEntries(new FormData(form).entries());
2952
    }
2953

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

            
Bogdan Timofte authored 6 days ago
2959
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
2960

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

            
2966
    if (loginAccount) {
2967
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
2968
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
2969
      loginAccount.addEventListener('input', () => {
2970
        const value = (loginAccount.value || '').trim();
2971
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
2972
      });
2973
    }
2974

            
Xdev Host Manager authored a week ago
2975
    function setOtpDigit(idx, value) {
2976
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
2977
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
2978
      otpDigits[idx].classList.toggle('filled', !!digit);
2979
    }
2980

            
Bogdan Timofte authored 4 days ago
2981
    // Move focus to the next empty box: forward from idx, then wrapping to the
2982
    // start. This lets out-of-order entry continue (e.g. after the last box,
2983
    // jump back to the first still-empty box). Stays put when all boxes are full.
2984
    function advanceFocus(idx) {
2985
      for (let i = idx + 1; i < otpDigits.length; i++) {
2986
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
2987
      }
2988
      for (let i = 0; i <= idx; i++) {
2989
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
2990
      }
2991
    }
2992

            
Bogdan Timofte authored 5 days ago
2993
    // Spread multiple digits across boxes starting at startIdx. Used for paste
2994
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
2995
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
2996
      const digits = (text || '').replace(/\D/g, '').split('');
2997
      if (!digits.length) return;
2998
      let last = startIdx;
2999
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3000
        last = startIdx + i;
3001
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3002
      }
Bogdan Timofte authored 5 days ago
3003
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3004
      advanceFocus(last);
Xdev Host Manager authored a week ago
3005
      maybeSubmitOtp();
3006
    }
3007

            
Bogdan Timofte authored 5 days ago
3008
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3009
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3010
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3011
    function maybeSubmitOtp() {
3012
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3013
    }
3014
    function clearOtp() {
Bogdan Timofte authored 5 days ago
3015
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3016
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3017
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3018
      // an unknown operator, so Safari's autofill anchor on the username stays.
3019
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3020
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3021
    }
3022

            
Bogdan Timofte authored 5 days ago
3023
    otpDigits.forEach((input, idx) => {
3024
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3025
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3026
        // A single box may receive several digits at once (autofill / typing fast).
3027
        if (input.value.replace(/\D/g, '').length > 1) {
3028
          fillOtp(input.value, idx);
3029
          return;
3030
        }
3031
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
3032
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3033
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
3034
        maybeSubmitOtp();
3035
      });
Bogdan Timofte authored 5 days ago
3036

            
3037
      input.addEventListener('paste', (e) => {
3038
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3039
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3040
        const text = (e.clipboardData || window.clipboardData).getData('text');
3041
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
3042
      });
Bogdan Timofte authored 5 days ago
3043

            
3044
      input.addEventListener('keydown', (e) => {
3045
        if (e.key === 'Backspace') {
3046
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3047
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3048
          if (input.value) { setOtpDigit(idx, ''); }
3049
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3050
          syncOtpFields();
3051
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3052
          e.preventDefault();
3053
          otpDigits[idx - 1].focus();
3054
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3055
          e.preventDefault();
3056
          otpDigits[idx + 1].focus();
3057
        }
3058
      });
3059
    });
3060

            
Bogdan Timofte authored 4 days ago
3061
    // Focus the first OTP box only for a returning operator (username known).
3062
    // For an unknown operator, leave focus on the username field so Safari can
3063
    // present its OTP autofill anchored there without being dismissed by a focus
3064
    // change (pbx-admin pattern).
3065
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3066
    else if (loginAccount) loginAccount.focus();
3067
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3068

            
Bogdan Timofte authored 5 days ago
3069
    document.querySelectorAll('[data-page-link]').forEach(link => {
3070
      link.addEventListener('click', (event) => {
3071
        event.preventDefault();
3072
        showPage(link.dataset.pageLink, true);
3073
      });
3074
    });
3075

            
3076
    window.addEventListener('popstate', () => showPage(currentPage()));
3077

            
Bogdan Timofte authored 4 days ago
3078
    async function copyText(text) {
3079
      if (navigator.clipboard && window.isSecureContext) {
3080
        await navigator.clipboard.writeText(text);
3081
        return;
3082
      }
3083
      const input = document.createElement('textarea');
3084
      input.value = text;
3085
      input.setAttribute('readonly', '');
3086
      input.style.position = 'fixed';
3087
      input.style.left = '-10000px';
3088
      document.body.appendChild(input);
3089
      input.select();
3090
      document.execCommand('copy');
3091
      document.body.removeChild(input);
3092
    }
3093

            
3094
    $('copy-build').addEventListener('click', async () => {
3095
      try {
3096
        await copyText($('copy-build').dataset.buildDetails || '');
3097
        if (state.authenticated) msg('build details copied');
3098
      } catch (e) {
3099
        if (state.authenticated) msg('copy failed');
3100
      }
3101
    });
3102

            
Xdev Host Manager authored a week ago
3103
    $('login-form').addEventListener('submit', async (event) => {
3104
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3105
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3106
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3107
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3108
      try {
Xdev Host Manager authored a week ago
3109
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3110
        await refresh();
Xdev Host Manager authored a week ago
3111
      } catch (e) {
3112
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3113
      } finally {
Xdev Host Manager authored a week ago
3114
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3115
      }
Xdev Host Manager authored a week ago
3116
    });
3117

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

            
Xdev Host Manager authored a week ago
3123
    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
3124
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 5 days ago
3125
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 5 days ago
3126
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
3127
    $('host-modal').addEventListener('click', (event) => {
3128
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
3129
    });
Bogdan Timofte authored 5 days ago
3130
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
3131
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
3132
    });
Xdev Host Manager authored a week ago
3133

            
Xdev Host Manager authored a week ago
3134
    $('host-form').addEventListener('submit', async (event) => {
3135
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3136
      setHostFormBusy(true);
3137
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
3138
      try {
3139
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
3140
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3141
        closeHostModal();
Xdev Host Manager authored a week ago
3142
        msg('host saved');
3143
        await refresh();
Bogdan Timofte authored 5 days ago
3144
      } catch (e) {
3145
        setHostFormMessage(e.message, true);
3146
        msg(e.message);
3147
      } finally {
3148
        setHostFormBusy(false);
3149
      }
3150
    });
3151

            
3152
    $('host-form').addEventListener('invalid', (event) => {
3153
      setHostFormMessage('Complete the required host fields before saving.', true);
3154
    }, true);
3155

            
3156
    $('host-form').addEventListener('input', () => {
3157
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
3158
    });
3159

            
3160
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
3161
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
3162
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
3163
      setHostFormBusy(true);
3164
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
3165
      try {
3166
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
3167
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
3168
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
3169
        closeHostModal();
Xdev Host Manager authored a week ago
3170
        msg('host deleted');
3171
        await refresh();
Bogdan Timofte authored 5 days ago
3172
      } catch (e) {
3173
        setHostFormMessage(e.message, true);
3174
        msg(e.message);
3175
      } finally {
3176
        setHostFormBusy(false);
3177
      }
Xdev Host Manager authored a week ago
3178
    });
3179

            
3180
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
3181
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
3182
      try {
3183
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
3184
        msg('local-hosts.tsv written');
3185
      } catch (e) { msg(e.message); }
3186
    });
3187

            
Xdev Host Manager authored a week ago
3188
    refresh().catch(() => showLogin());
Xdev Host Manager authored a week ago
3189
  </script>
3190
</body>
3191
</html>
3192
HTML
Bogdan Timofte authored 6 days ago
3193
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
3194
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
3195
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
3196
    return $html;
Xdev Host Manager authored a week ago
3197
}