LocalAuthority / scripts / host_manager.pl
Newer Older
2376 lines | 88.79kb
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 6 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 6 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 6 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 = parse_hosts_yaml(load_operational_doc('hosts_yaml', $opt{data}, default_hosts_yaml()));
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);
224
    save_operational_doc('hosts_yaml', render_hosts_yaml($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 parse_work_orders_yaml(load_operational_doc('work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
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_operational_doc('work_orders_yaml', render_work_orders_yaml($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 6 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";
585
    return $out || '{}';
586
}
587

            
Xdev Host Manager authored a week ago
588
sub parse_hosts_yaml {
589
    my ($text) = @_;
590
    my %registry = (
591
        version => 1,
592
        updated_at => '',
593
        policy => {},
594
        hosts => [],
595
    );
596
    my ($section, $current, $list_key);
597
    for my $line (split /\n/, $text) {
598
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
599
        if ($line =~ /^version:\s*(\d+)/) {
600
            $registry{version} = int($1);
601
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
602
            $registry{updated_at} = yaml_unquote($1);
603
        } elsif ($line =~ /^policy:\s*$/) {
604
            $section = 'policy';
605
        } elsif ($line =~ /^hosts:\s*$/) {
606
            $section = 'hosts';
607
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
608
            $registry{policy}{$1} = yaml_unquote($2);
609
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
610
            $current = {
611
                id => yaml_unquote($1),
612
                status => 'active',
613
                hosts_ip => '',
614
                dns_ip => '',
615
                names => [],
616
                roles => [],
617
                sources => [],
618
                monitoring => 'pending',
619
                notes => '',
620
            };
621
            push @{ $registry{hosts} }, $current;
622
            $list_key = undef;
623
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
624
            $list_key = $1;
625
            $current->{$list_key} ||= [];
626
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
627
            push @{ $current->{$list_key} }, yaml_unquote($1);
628
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
629
            $current->{$1} = yaml_unquote($2);
630
            $list_key = undef;
631
        }
632
    }
633
    return \%registry;
634
}
635

            
636
sub render_hosts_yaml {
637
    my ($registry) = @_;
638
    my $out = "version: " . int($registry->{version} || 1) . "\n";
639
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
640
    $out .= "policy:\n";
641
    for my $key (sort keys %{ $registry->{policy} || {} }) {
642
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
643
    }
644
    $out .= "hosts:\n";
645
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
646
        $out .= "  - id: " . yq($host->{id}) . "\n";
647
        for my $key (qw(status hosts_ip dns_ip)) {
648
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
649
        }
650
        for my $key (qw(names roles sources)) {
651
            $out .= "    $key:\n";
652
            for my $value (@{ $host->{$key} || [] }) {
653
                $out .= "      - " . yq($value) . "\n";
654
            }
655
        }
656
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
657
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
658
    }
659
    return $out;
660
}
661

            
Xdev Host Manager authored a week ago
662
sub parse_work_orders_yaml {
663
    my ($text) = @_;
664
    my %orders = (
665
        version => 1,
666
        work_orders => [],
667
    );
Xdev Host Manager authored a week ago
668
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
669
    for my $line (split /\n/, $text) {
670
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
671
        if ($line =~ /^version:\s*(\d+)/) {
672
            $orders{version} = int($1);
673
        } elsif ($line =~ /^work_orders:\s*$/) {
674
            $section = 'work_orders';
675
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
676
            $current = {
677
                id => yaml_unquote($1),
678
                status => 'pending',
Xdev Host Manager authored a week ago
679
                checklist => [],
Xdev Host Manager authored a week ago
680
                actions => [],
681
            };
682
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
683
            $list_section = '';
Xdev Host Manager authored a week ago
684
            $current_action = undef;
Xdev Host Manager authored a week ago
685
            $current_item = undef;
686
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
687
            $list_section = 'checklist';
688
            $current->{checklist} ||= [];
689
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
690
            $current_item = { id => yaml_unquote($1), status => 'pending' };
691
            push @{ $current->{checklist} }, $current_item;
692
            $current_action = undef;
693
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
694
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
695
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
696
            $list_section = 'actions';
Xdev Host Manager authored a week ago
697
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
698
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
699
            $current_action = { type => yaml_unquote($1) };
700
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
701
            $current_item = undef;
702
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
703
            $current_action->{$1} = yaml_unquote($2);
704
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
705
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
706
            $list_section = '';
Xdev Host Manager authored a week ago
707
            $current_action = undef;
Xdev Host Manager authored a week ago
708
            $current_item = undef;
Xdev Host Manager authored a week ago
709
        }
710
    }
711
    return \%orders;
712
}
713

            
714
sub render_work_orders_yaml {
715
    my ($orders) = @_;
716
    my $out = "version: " . int($orders->{version} || 1) . "\n";
717
    $out .= "work_orders:\n";
718
    for my $wo (@{ $orders->{work_orders} || [] }) {
719
        $out .= "  - id: " . yq($wo->{id}) . "\n";
720
        for my $key (qw(status title reason created_at confirmed_at result)) {
721
            next unless exists $wo->{$key} && length($wo->{$key} || '');
722
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
723
        }
Xdev Host Manager authored a week ago
724
        $out .= "    checklist:\n";
725
        for my $item (@{ $wo->{checklist} || [] }) {
726
            $out .= "      - id: " . yq($item->{id}) . "\n";
727
            for my $key (qw(text status owner notes updated_at)) {
728
                next unless exists $item->{$key} && length($item->{$key} || '');
729
                $out .= "        $key: " . yq($item->{$key}) . "\n";
730
            }
731
        }
Xdev Host Manager authored a week ago
732
        $out .= "    actions:\n";
733
        for my $action (@{ $wo->{actions} || [] }) {
734
            $out .= "      - type: " . yq($action->{type}) . "\n";
735
            for my $key (qw(host_id name)) {
736
                next unless exists $action->{$key} && length($action->{$key} || '');
737
                $out .= "        $key: " . yq($action->{$key}) . "\n";
738
            }
739
        }
740
    }
741
    return $out;
742
}
743

            
Xdev Host Manager authored a week ago
744
sub request_payload {
745
    my ($headers, $body) = @_;
746
    my $type = $headers->{'content-type'} || '';
747
    if ($type =~ m{application/json}) {
748
        return json_decode($body || '{}');
749
    }
750
    return { parse_params($body || '') };
751
}
752

            
753
sub json_bool {
754
    my ($value) = @_;
755
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
756
}
757

            
758
sub json_encode {
759
    my ($value) = @_;
760
    if (!defined $value) {
761
        return 'null';
762
    }
763
    my $ref = ref($value);
764
    if (!$ref) {
765
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
766
        return json_string($value);
767
    }
768
    if ($ref eq 'HostManager::JSONBool') {
769
        return $$value ? 'true' : 'false';
770
    }
771
    if ($ref eq 'ARRAY') {
772
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
773
    }
774
    if ($ref eq 'HASH') {
775
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
776
    }
777
    return json_string("$value");
778
}
779

            
780
sub json_string {
781
    my ($value) = @_;
782
    $value = '' unless defined $value;
783
    $value =~ s/\\/\\\\/g;
784
    $value =~ s/"/\\"/g;
785
    $value =~ s/\n/\\n/g;
786
    $value =~ s/\r/\\r/g;
787
    $value =~ s/\t/\\t/g;
788
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
789
    return qq("$value");
790
}
791

            
792
sub json_decode {
793
    my ($text) = @_;
794
    my $i = 0;
795
    my $len = length($text);
796
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
797

            
798
    $skip_ws = sub {
799
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
800
    };
801

            
802
    $parse_string = sub {
803
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
804
        $i++;
805
        my $out = '';
806
        while ($i < $len) {
807
            my $ch = substr($text, $i++, 1);
808
            return $out if $ch eq '"';
809
            if ($ch eq "\\") {
810
                die "Bad JSON escape\n" if $i >= $len;
811
                my $esc = substr($text, $i++, 1);
812
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
813
                    $out .= $esc;
814
                } elsif ($esc eq 'b') {
815
                    $out .= "\b";
816
                } elsif ($esc eq 'f') {
817
                    $out .= "\f";
818
                } elsif ($esc eq 'n') {
819
                    $out .= "\n";
820
                } elsif ($esc eq 'r') {
821
                    $out .= "\r";
822
                } elsif ($esc eq 't') {
823
                    $out .= "\t";
824
                } elsif ($esc eq 'u') {
825
                    my $hex = substr($text, $i, 4);
826
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
827
                    $out .= chr(hex($hex));
828
                    $i += 4;
829
                } else {
830
                    die "Bad JSON escape\n";
831
                }
832
            } else {
833
                $out .= $ch;
834
            }
835
        }
836
        die "Unterminated JSON string\n";
837
    };
838

            
839
    $parse_number = sub {
840
        my $start = $i;
841
        $i++ if substr($text, $i, 1) eq '-';
842
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
843
        if ($i < $len && substr($text, $i, 1) eq '.') {
844
            $i++;
845
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
846
        }
847
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
848
            $i++;
849
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
850
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
851
        }
852
        return 0 + substr($text, $start, $i - $start);
853
    };
854

            
855
    $parse_array = sub {
856
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
857
        $i++;
858
        my @out;
859
        $skip_ws->();
860
        if ($i < $len && substr($text, $i, 1) eq ']') {
861
            $i++;
862
            return \@out;
863
        }
864
        while (1) {
865
            push @out, $parse_value->();
866
            $skip_ws->();
867
            my $ch = substr($text, $i++, 1);
868
            last if $ch eq ']';
869
            die "Expected JSON array comma\n" unless $ch eq ',';
870
        }
871
        return \@out;
872
    };
873

            
874
    $parse_object = sub {
875
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
876
        $i++;
877
        my %out;
878
        $skip_ws->();
879
        if ($i < $len && substr($text, $i, 1) eq '}') {
880
            $i++;
881
            return \%out;
882
        }
883
        while (1) {
884
            $skip_ws->();
885
            my $key = $parse_string->();
886
            $skip_ws->();
887
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
888
            $out{$key} = $parse_value->();
889
            $skip_ws->();
890
            my $ch = substr($text, $i++, 1);
891
            last if $ch eq '}';
892
            die "Expected JSON object comma\n" unless $ch eq ',';
893
        }
894
        return \%out;
895
    };
896

            
897
    $parse_value = sub {
898
        $skip_ws->();
899
        die "Unexpected end of JSON\n" if $i >= $len;
900
        my $ch = substr($text, $i, 1);
901
        return $parse_string->() if $ch eq '"';
902
        return $parse_object->() if $ch eq '{';
903
        return $parse_array->() if $ch eq '[';
904
        if (substr($text, $i, 4) eq 'true') {
905
            $i += 4;
906
            return json_bool(1);
907
        }
908
        if (substr($text, $i, 5) eq 'false') {
909
            $i += 5;
910
            return json_bool(0);
911
        }
912
        if (substr($text, $i, 4) eq 'null') {
913
            $i += 4;
914
            return undef;
915
        }
916
        return $parse_number->() if $ch =~ /[-0-9]/;
917
        die "Unexpected JSON token\n";
918
    };
919

            
920
    my $value = $parse_value->();
921
    $skip_ws->();
922
    die "Trailing JSON content\n" if $i != $len;
923
    return $value;
924
}
925

            
926
sub parse_params {
927
    my ($text) = @_;
928
    my %out;
929
    for my $pair (split /&/, $text) {
930
        next unless length $pair;
931
        my ($k, $v) = split /=/, $pair, 2;
932
        $out{url_decode($k)} = url_decode($v || '');
933
    }
934
    return %out;
935
}
936

            
937
sub clean_id {
938
    my ($value) = @_;
939
    $value = lc clean_scalar($value);
940
    $value =~ s/[^a-z0-9_.-]+/-/g;
941
    $value =~ s/^-+|-+$//g;
942
    return $value;
943
}
944

            
945
sub clean_scalar {
946
    my ($value) = @_;
947
    $value = '' unless defined $value;
948
    $value =~ s/[\r\n\t]+/ /g;
949
    $value =~ s/^\s+|\s+$//g;
950
    return $value;
951
}
952

            
953
sub clean_list {
954
    my ($value) = @_;
955
    return () unless defined $value;
956
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
957
    my @clean;
958
    for my $item (@items) {
959
        $item = clean_scalar($item);
960
        push @clean, $item if length $item;
961
    }
962
    return @clean;
963
}
964

            
965
sub yq {
966
    my ($value) = @_;
967
    $value = '' unless defined $value;
968
    $value =~ s/\\/\\\\/g;
969
    $value =~ s/"/\\"/g;
970
    return qq("$value");
971
}
972

            
973
sub yaml_unquote {
974
    my ($value) = @_;
975
    $value = '' unless defined $value;
976
    $value =~ s/^\s+|\s+$//g;
977
    if ($value =~ /^"(.*)"$/) {
978
        $value = $1;
979
        $value =~ s/\\"/"/g;
980
        $value =~ s/\\\\/\\/g;
981
    }
982
    return $value;
983
}
984

            
985
sub verify_totp {
986
    my ($secret, $otp) = @_;
987
    return 0 unless $secret && $otp =~ /^\d{6}$/;
988
    my $key = eval { base32_decode($secret) };
989
    return 0 if $@ || !length $key;
990
    my $counter = int(time() / 30);
991
    for my $offset (-1, 0, 1) {
992
        return 1 if totp_code($key, $counter + $offset) eq $otp;
993
    }
994
    return 0;
995
}
996

            
997
sub totp_code {
998
    my ($key, $counter) = @_;
999
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1000
    my $hash = hmac_sha1($msg, $key);
1001
    my $offset = ord(substr($hash, -1)) & 0x0f;
1002
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1003
    return sprintf('%06d', $bin % 1_000_000);
1004
}
1005

            
1006
sub base32_decode {
1007
    my ($text) = @_;
1008
    $text = uc($text || '');
1009
    $text =~ s/[^A-Z2-7]//g;
1010
    my %map;
1011
    my @chars = ('A'..'Z', '2'..'7');
1012
    @map{@chars} = (0..31);
1013
    my ($bits, $value, $out) = (0, 0, '');
1014
    for my $char (split //, $text) {
1015
        die "Invalid base32\n" unless exists $map{$char};
1016
        $value = ($value << 5) | $map{$char};
1017
        $bits += 5;
1018
        while ($bits >= 8) {
1019
            $bits -= 8;
1020
            $out .= chr(($value >> $bits) & 0xff);
1021
        }
1022
    }
1023
    return $out;
1024
}
1025

            
1026
sub create_session {
1027
    my $nonce = random_hex(24);
1028
    my $expires = int(time() + 8 * 3600);
1029
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1030
    my $token = "$nonce:$expires:$sig";
1031
    $sessions{$token} = $expires;
1032
    return $token;
1033
}
1034

            
1035
sub is_authenticated {
1036
    my ($headers) = @_;
1037
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1038
    return 0 unless $token;
1039
    my ($nonce, $expires, $sig) = split /:/, $token;
1040
    return 0 unless $nonce && $expires && $sig;
1041
    return 0 if $expires < time();
1042
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1043
    return exists $sessions{$token};
1044
}
1045

            
1046
sub expire_session {
1047
    my ($headers) = @_;
1048
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1049
    delete $sessions{$token} if $token;
1050
}
1051

            
1052
sub cookie_value {
1053
    my ($cookie, $name) = @_;
1054
    for my $part (split /;\s*/, $cookie) {
1055
        my ($k, $v) = split /=/, $part, 2;
1056
        return $v if defined $k && $k eq $name;
1057
    }
1058
    return '';
1059
}
1060

            
1061
sub send_json {
1062
    my ($client, $status, $payload, $extra_headers) = @_;
1063
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1064
}
1065

            
Xdev Host Manager authored a week ago
1066
sub send_json_raw {
1067
    my ($client, $status, $json_body, $extra_headers) = @_;
1068
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1069
}
1070

            
Xdev Host Manager authored a week ago
1071
sub send_html {
1072
    my ($client, $status, $html) = @_;
1073
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1074
}
1075

            
1076
sub send_text {
1077
    my ($client, $status, $text) = @_;
1078
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1079
}
1080

            
1081
sub send_download {
1082
    my ($client, $status, $content, $type, $filename) = @_;
1083
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1084
}
1085

            
1086
sub send_file {
1087
    my ($client, $path, $type, $filename) = @_;
1088
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1089
    return send_download($client, 200, read_file($path), $type, $filename);
1090
}
1091

            
1092
sub send_response {
1093
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1094
    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
1095
    $body = '' unless defined $body;
1096
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1097
    print $client "Content-Type: $type\r\n";
1098
    print $client "Content-Length: " . length($body) . "\r\n";
1099
    print $client "Cache-Control: no-store\r\n";
1100
    print $client "$_\r\n" for @{ $extra_headers || [] };
1101
    print $client "Connection: close\r\n\r\n";
1102
    print $client $body;
1103
}
1104

            
1105
sub read_file {
1106
    my ($path) = @_;
1107
    open my $fh, '<', $path or die "Cannot read $path: $!";
1108
    local $/;
1109
    return <$fh>;
1110
}
1111

            
1112
sub write_file {
1113
    my ($path, $content) = @_;
1114
    open my $fh, '>', $path or die "Cannot write $path: $!";
1115
    print {$fh} $content;
1116
    close $fh or die "Cannot close $path: $!";
1117
}
1118

            
1119
sub backup_file {
1120
    my ($path) = @_;
1121
    return unless -f $path;
1122
    my $backup_dir = "$project_dir/backups/host-manager";
1123
    make_path($backup_dir) unless -d $backup_dir;
1124
    my $name = $path;
1125
    $name =~ s{.*/}{};
1126
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1127
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1128
}
1129

            
Bogdan Timofte authored 4 days ago
1130
my $db_handle;
1131

            
1132
sub dbh {
1133
    return $db_handle if $db_handle;
1134
    ensure_parent_dir($opt{db});
1135
    $db_handle = DBI->connect(
1136
        "dbi:SQLite:dbname=$opt{db}",
1137
        '',
1138
        '',
1139
        {
1140
            RaiseError => 1,
1141
            PrintError => 0,
1142
            AutoCommit => 1,
1143
            sqlite_unicode => 1,
1144
        },
1145
    ) or die "Cannot open SQLite database $opt{db}\n";
1146
    $db_handle->do('PRAGMA journal_mode = WAL');
1147
    $db_handle->do('PRAGMA foreign_keys = ON');
1148
    $db_handle->do(<<'SQL');
1149
CREATE TABLE IF NOT EXISTS documents (
1150
    name TEXT PRIMARY KEY,
1151
    content TEXT NOT NULL,
1152
    updated_at TEXT NOT NULL
1153
)
1154
SQL
1155
    return $db_handle;
1156
}
1157

            
1158
sub load_operational_doc {
1159
    my ($name, $seed_path, $default_text) = @_;
1160
    my $dbh = dbh();
1161
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
1162
    return $row->{content} if $row;
1163

            
1164
    my $content = -f $seed_path ? read_file($seed_path) : $default_text;
1165
    save_operational_doc($name, $content);
1166
    return $content;
1167
}
1168

            
1169
sub save_operational_doc {
1170
    my ($name, $content) = @_;
1171
    my $dbh = dbh();
1172
    $dbh->do(
1173
        'INSERT INTO documents (name, content, updated_at) VALUES (?, ?, ?) '
1174
        . 'ON CONFLICT(name) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at',
1175
        undef,
1176
        $name,
1177
        $content,
1178
        iso_now(),
1179
    );
1180
}
1181

            
1182
sub normalize_registry_policy {
1183
    my ($registry) = @_;
1184
    $registry->{policy} ||= {};
1185
    $registry->{policy}{storage_authority} = 'sqlite';
1186
    $registry->{policy}{runtime_database} = $opt{db};
1187
}
1188

            
1189
sub default_hosts_yaml {
1190
    return <<'YAML';
1191
version: 1
1192
updated_at: ""
1193
policy:
1194
  storage_authority: "sqlite"
1195
hosts:
1196
YAML
1197
}
1198

            
1199
sub default_work_orders_yaml {
1200
    return <<'YAML';
1201
version: 1
1202
work_orders:
1203
YAML
1204
}
1205

            
1206
sub ensure_parent_dir {
1207
    my ($path) = @_;
1208
    my $dir = dirname($path);
1209
    make_path($dir) unless -d $dir;
1210
}
1211

            
Xdev Host Manager authored a week ago
1212
sub url_decode {
1213
    my ($value) = @_;
1214
    $value = '' unless defined $value;
1215
    $value =~ tr/+/ /;
1216
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
1217
    return $value;
1218
}
1219

            
1220
sub random_hex {
1221
    my ($bytes) = @_;
1222
    if (open my $fh, '<:raw', '/dev/urandom') {
1223
        read($fh, my $raw, $bytes);
1224
        close $fh;
1225
        return unpack('H*', $raw);
1226
    }
1227
    return sha256_hex(rand() . time() . $$);
1228
}
1229

            
1230
sub iso_now {
1231
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
1232
}
1233

            
Bogdan Timofte authored 6 days ago
1234
sub build_info {
1235
    my %info = (
1236
        revision => '',
1237
        branch => '',
1238
        built_at => '',
1239
        deployed_at => '',
1240
        dirty => '',
1241
    );
1242

            
1243
    if ($ENV{HOST_MANAGER_BUILD}) {
1244
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
1245
        return \%info;
1246
    }
1247

            
1248
    my $build_file = "$project_dir/BUILD";
1249
    if (-f $build_file) {
1250
        for my $line (split /\n/, read_file($build_file)) {
1251
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
1252
            $info{$1} = clean_scalar($2);
1253
        }
1254
        return \%info if $info{revision} || $info{built_at};
1255
    }
1256

            
1257
    my $revision = git_value('rev-parse --short=12 HEAD');
1258
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
1259
    $info{revision} = $revision if $revision;
1260
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
1261
    return \%info;
1262
}
1263

            
1264
sub git_value {
1265
    my ($args) = @_;
1266
    return '' unless -d "$project_dir/.git";
1267
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
1268
    my $value = <$fh> || '';
1269
    close $fh;
1270
    chomp $value;
1271
    return clean_scalar($value);
1272
}
1273

            
1274
sub build_label {
1275
    my $info = build_info();
1276
    my $revision = $info->{revision} || 'unknown';
1277
    my $branch = $info->{branch} || '';
1278
    $branch = '' if $branch eq 'HEAD';
1279
    my $label = $branch ? "$branch $revision" : $revision;
1280
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
1281
    return $label;
1282
}
1283

            
1284
sub build_title {
1285
    my $info = build_info();
1286
    my $label = build_label();
1287
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
1288
    return $stamp ? "$label deployed $stamp" : $label;
1289
}
1290

            
Bogdan Timofte authored 5 days ago
1291
sub build_revision {
1292
    my $info = build_info();
1293
    return $info->{revision} || 'unknown';
1294
}
1295

            
1296
sub build_details {
1297
    my $info = build_info();
1298
    my %details = (
1299
        app => 'Madagascar Local Authority',
1300
        revision => $info->{revision} || 'unknown',
1301
        branch => $info->{branch} || '',
1302
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
1303
        built_at => $info->{built_at} || '',
1304
        deployed_at => $info->{deployed_at} || '',
1305
        label => build_label(),
1306
        title => build_title(),
1307
    );
1308
    return json_encode(\%details);
1309
}
1310

            
Bogdan Timofte authored 6 days ago
1311
sub html_escape {
1312
    my ($value) = @_;
1313
    $value = '' unless defined $value;
1314
    $value =~ s/&/&amp;/g;
1315
    $value =~ s/</&lt;/g;
1316
    $value =~ s/>/&gt;/g;
1317
    $value =~ s/"/&quot;/g;
1318
    $value =~ s/'/&#039;/g;
1319
    return $value;
1320
}
1321

            
Xdev Host Manager authored a week ago
1322
sub app_html {
Bogdan Timofte authored 5 days ago
1323
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
1324
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 5 days ago
1325
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
1326
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
1327
<!doctype html>
1328
<html lang="ro">
1329
<head>
1330
  <meta charset="utf-8">
1331
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
1332
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
1333
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
1334
  <style>
1335
    :root {
1336
      color-scheme: light;
1337
      --ink: #152033;
1338
      --muted: #647084;
1339
      --line: #d8dee8;
1340
      --soft: #f4f6f9;
1341
      --panel: #ffffff;
1342
      --accent: #1267d8;
1343
      --bad: #b42318;
1344
      --warn: #946200;
1345
      --ok: #137333;
1346
    }
1347
    * { box-sizing: border-box; }
1348
    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
1349

            
1350
    /* ── Login screen ── */
1351
    #login-screen {
1352
      display: flex;
Xdev Host Manager authored a week ago
1353
      align-items: flex-start;
Xdev Host Manager authored a week ago
1354
      justify-content: center;
1355
      min-height: 100dvh;
Xdev Host Manager authored a week ago
1356
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
1357
      background: #13182a;
Xdev Host Manager authored a week ago
1358
      overflow: auto;
Xdev Host Manager authored a week ago
1359
    }
1360
    .login-card {
Xdev Host Manager authored a week ago
1361
      --otp-size: 48px;
Xdev Host Manager authored a week ago
1362
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
1363
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
1364
      background: #fff;
1365
      border-radius: 16px;
Bogdan Timofte authored 5 days ago
1366
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
1367
         below the first box, sits inside the card instead of spilling past it. */
1368
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
1369
      width: 100%;
Xdev Host Manager authored a week ago
1370
      max-width: 680px;
Bogdan Timofte authored 6 days ago
1371
      min-height: 360px;
Xdev Host Manager authored a week ago
1372
      display: grid;
Xdev Host Manager authored a week ago
1373
      align-content: start;
1374
      justify-items: center;
1375
      gap: 28px;
Xdev Host Manager authored a week ago
1376
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
1377
    }
Xdev Host Manager authored a week ago
1378
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
1379
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
1380
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
1381
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
1382
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
1383
    }
Xdev Host Manager authored a week ago
1384
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
1385
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
1386
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
1387
    .login-card form {
1388
      display: grid;
1389
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
1390
      justify-self: center;
Bogdan Timofte authored a week ago
1391
      padding-bottom: 0;
Xdev Host Manager authored a week ago
1392
    }
Xdev Host Manager authored a week ago
1393
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 5 days ago
1394
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
1395
       giving the password manager a username anchor and an aggregated OTP target
1396
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
1397
    .pm-helper-fields {
1398
      position: absolute;
1399
      left: -10000px;
1400
      top: auto;
1401
      width: 1px;
1402
      height: 1px;
1403
      overflow: hidden;
1404
      opacity: 0.01;
1405
    }
1406
    .pm-helper-fields input {
1407
      width: 1px;
1408
      height: 1px;
1409
      padding: 0;
1410
      border: 0;
1411
    }
Bogdan Timofte authored 5 days ago
1412
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
1413
       hint was what made Safari mark the whole group and re-present its OTP
1414
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
1415
    .otp-row {
1416
      display: flex;
1417
      gap: var(--otp-gap);
1418
      justify-content: center;
1419
    }
Bogdan Timofte authored 5 days ago
1420
    .otp-row input {
Xdev Host Manager authored a week ago
1421
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
1422
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
1423
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
1424
      transition: border-color .15s, background .15s;
1425
    }
Bogdan Timofte authored 5 days ago
1426
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
1427
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
1428
    #login-error {
1429
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 5 days ago
1430
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
1431
    }
1432
    @media (max-width: 760px) {
1433
      .login-card {
Xdev Host Manager authored a week ago
1434
        max-width: 520px;
Xdev Host Manager authored a week ago
1435
        min-height: 0;
Bogdan Timofte authored 5 days ago
1436
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
1437
        gap: 26px;
1438
      }
1439
      .login-card .brand h1 { font-size: 24px; }
1440
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
1441
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
1442
    }
Xdev Host Manager authored a week ago
1443
    @media (max-width: 430px) {
1444
      #login-screen { padding: 24px 16px 120px; }
1445
      .login-card {
1446
        --otp-size: 42px;
Xdev Host Manager authored a week ago
1447
        --otp-gap: 12px;
Bogdan Timofte authored 5 days ago
1448
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
1449
      }
Bogdan Timofte authored 5 days ago
1450
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
1451
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
1452
    }
1453
    @media (max-height: 720px) {
1454
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 5 days ago
1455
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
1456
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
1457
    }
Xdev Host Manager authored a week ago
1458

            
1459
    /* ── App shell (hidden until authenticated) ── */
1460
    #app { display: none; }
Bogdan Timofte authored 6 days ago
1461
    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
1462
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 6 days ago
1463
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
1464
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
1465
    nav a:hover { color: var(--ink); background: var(--soft); }
1466
    nav a.active { color: var(--accent); background: #e8f0fe; }
1467
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
1468
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
1469
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 6 days ago
1470
    .page { display: grid; gap: 16px; }
1471
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
1472
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
1473
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
1474
    .panel { overflow: hidden; }
1475
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
1476
    .panel-head h2 { margin: 0; font-size: 14px; }
1477
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
1478
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
1479
    button, input, select, textarea { font: inherit; }
1480
    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; }
1481
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
1482
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
1483
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
1484
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
1485
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
1486
    textarea { min-height: 74px; resize: vertical; }
1487
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
1488
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
1489
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
1490
    tr:hover td { background: #f8fafc; }
1491
    .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; }
1492
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
1493
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
1494
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
1495
    .pill.derived { border-style: dashed; }
Xdev Host Manager authored a week ago
1496
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
1497
    .span2 { grid-column: 1 / -1; }
1498
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
1499
    .muted { color: var(--muted); }
Bogdan Timofte authored 6 days ago
1500
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
1501
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
1502
    .ca-fingerprint { overflow-wrap: anywhere; }
1503
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 5 days ago
1504
    .build-control {
Bogdan Timofte authored 6 days ago
1505
      position: fixed;
1506
      right: 10px;
1507
      bottom: 8px;
1508
      z-index: 5;
Bogdan Timofte authored 5 days ago
1509
      display: inline-flex;
1510
      align-items: center;
1511
      gap: 4px;
1512
    }
1513
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
1514
      color: rgba(255,255,255,.46);
1515
      background: rgba(19,24,42,.28);
1516
      border: 1px solid rgba(255,255,255,.08);
1517
      border-radius: 4px;
1518
      font-size: 10px;
1519
      line-height: 1.2;
Bogdan Timofte authored 5 days ago
1520
    }
1521
    .build-badge {
1522
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
1523
      cursor: text;
1524
      user-select: text;
Bogdan Timofte authored 6 days ago
1525
    }
Bogdan Timofte authored 5 days ago
1526
    .build-copy {
1527
      min-height: 0;
1528
      padding: 2px 5px;
1529
      cursor: pointer;
1530
    }
1531
    .build-copy:hover {
1532
      color: rgba(255,255,255,.72);
1533
      border-color: rgba(255,255,255,.24);
1534
    }
1535
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
1536
      color: rgba(100,112,132,.58);
1537
      background: rgba(255,255,255,.72);
1538
      border-color: rgba(216,222,232,.72);
1539
    }
Bogdan Timofte authored 5 days ago
1540
    body.is-app .build-copy:hover {
1541
      color: rgba(21,32,51,.78);
1542
      border-color: rgba(100,112,132,.42);
1543
    }
Xdev Host Manager authored a week ago
1544
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
1545
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
1546
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
1547
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1548
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
1549
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
1550
    .work-order-actions { gap: 4px; }
1551
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
1552
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
1553
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 6 days ago
1554
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
1555
    .host-tools input { max-width: 240px; }
1556
    .modal-backdrop {
1557
      position: fixed;
1558
      inset: 0;
1559
      z-index: 10;
1560
      display: grid;
1561
      align-items: start;
1562
      justify-items: center;
1563
      padding: 72px 16px 24px;
1564
      background: rgba(21,32,51,.48);
1565
      overflow: auto;
1566
    }
1567
    .modal-backdrop[hidden] { display: none; }
1568
    .modal {
1569
      width: min(840px, 100%);
1570
      max-height: calc(100dvh - 96px);
1571
      overflow: auto;
1572
      background: var(--panel);
1573
      border: 1px solid var(--line);
1574
      border-radius: 8px;
1575
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
1576
    }
1577
    .modal-head {
1578
      position: sticky;
1579
      top: 0;
1580
      z-index: 1;
1581
      display: flex;
1582
      align-items: center;
1583
      justify-content: space-between;
1584
      gap: 12px;
1585
      padding: 12px 14px;
1586
      border-bottom: 1px solid var(--line);
1587
      background: #fafbfc;
1588
    }
1589
    .modal-head h2 { margin: 0; font-size: 14px; }
1590
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 6 days ago
1591
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
1592
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 6 days ago
1593
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
1594
    @media (max-width: 760px) {
Bogdan Timofte authored 6 days ago
1595
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
1596
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
1597
      #message { max-width: 100%; }
1598
      .panel-head { align-items: stretch; flex-direction: column; }
1599
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
1600
      .host-tools input { max-width: none; }
1601
      .modal-backdrop { padding-top: 16px; }
1602
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
1603
      .grid { grid-template-columns: 1fr; }
1604
      table { min-width: 760px; }
1605
      .table-wrap { overflow-x: auto; }
1606
    }
1607
  </style>
1608
</head>
Bogdan Timofte authored 6 days ago
1609
<body class="is-login">
Xdev Host Manager authored a week ago
1610

            
Xdev Host Manager authored a week ago
1611
  <!-- ── Login screen ── -->
1612
  <div id="login-screen">
1613
    <div class="login-card">
1614
      <div class="brand">
1615
        <div class="icon">
Xdev Host Manager authored a week ago
1616
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1617
            <rect x="16" y="10" width="32" height="44" rx="4"/>
1618
            <rect x="21" y="16" width="22" height="8" rx="2"/>
1619
            <rect x="21" y="28" width="22" height="8" rx="2"/>
1620
            <rect x="21" y="40" width="22" height="8" rx="2"/>
1621
            <path d="M26 20h8M26 32h8M26 44h8"/>
1622
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
1623
          </svg>
1624
        </div>
Xdev Host Manager authored a week ago
1625
        <h1>Madagascar Local Authority</h1>
1626
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
1627
      </div>
Bogdan Timofte authored 5 days ago
1628
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
1629
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
1630
        <div class="pm-helper-fields" aria-hidden="true">
1631
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
1632
          <input type="hidden" id="otp-hidden" name="otp">
1633
        </div>
Xdev Host Manager authored a week ago
1634
        <div class="otp-row">
Bogdan Timofte authored 5 days ago
1635
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
1636
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
1637
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
1638
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
1639
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
1640
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
1641
        </div>
1642
      </form>
1643
    </div>
1644
  </div>
1645

            
1646
  <!-- ── App (shown after login) ── -->
1647
  <div id="app">
1648
    <header>
Xdev Host Manager authored a week ago
1649
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 6 days ago
1650
      <nav aria-label="Sections">
1651
        <a href="/overview" data-page-link="overview">Overview</a>
1652
        <a href="/hosts" data-page-link="hosts">Hosts</a>
1653
        <a href="/dns" data-page-link="dns">DNS</a>
1654
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
1655
        <a href="/ca" data-page-link="ca">Local CA</a>
1656
      </nav>
Xdev Host Manager authored a week ago
1657
      <div class="header-right">
1658
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 6 days ago
1659
        <span id="message" class="muted"></span>
1660
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
1661
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
1662
      </div>
Xdev Host Manager authored a week ago
1663
    </header>
1664
    <main>
Bogdan Timofte authored 6 days ago
1665
      <section class="page" id="page-overview" data-page="overview">
1666
        <section class="panel">
1667
          <div class="panel-head">
1668
            <h2>Overview</h2>
1669
            <div class="stats" id="stats"></div>
1670
          </div>
1671
          <div class="problems" id="problems"></div>
1672
        </section>
Xdev Host Manager authored a week ago
1673
      </section>
1674

            
Bogdan Timofte authored 6 days ago
1675
      <section class="page" id="page-hosts" data-page="hosts" hidden>
1676
        <section class="panel">
1677
          <div class="panel-head">
1678
            <h2>Hosts</h2>
1679
            <div class="host-tools">
1680
              <input id="filter" placeholder="filter">
1681
              <button type="button" id="new-host">New host</button>
1682
            </div>
1683
          </div>
1684
          <div class="table-wrap">
1685
            <table>
1686
              <thead>
1687
                <tr>
1688
                  <th style="width: 120px">ID</th>
1689
                  <th style="width: 130px">hosts_ip</th>
1690
                  <th style="width: 130px">dns_ip</th>
1691
                  <th>Names</th>
1692
                  <th style="width: 150px">Roles</th>
1693
                  <th style="width: 110px">Monitoring</th>
1694
                  <th style="width: 90px">Status</th>
1695
                </tr>
1696
              </thead>
1697
              <tbody id="hosts"></tbody>
1698
            </table>
1699
          </div>
1700
        </section>
Xdev Host Manager authored a week ago
1701
      </section>
Xdev Host Manager authored a week ago
1702

            
Bogdan Timofte authored 6 days ago
1703
      <section class="page" id="page-dns" data-page="dns" hidden>
1704
        <section class="toolbar">
1705
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
1706
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
1707
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
1708
          <button id="write-tsv">Write local-hosts.tsv</button>
1709
        </section>
Xdev Host Manager authored a week ago
1710
      </section>
1711

            
Bogdan Timofte authored 6 days ago
1712
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
1713
        <section class="panel">
1714
          <div class="panel-head">
1715
            <h2>Work Orders</h2>
1716
            <div class="stats" id="wo-stats"></div>
1717
          </div>
1718
          <div class="problems" id="work-orders"></div>
1719
        </section>
Xdev Host Manager authored a week ago
1720
      </section>
1721

            
Bogdan Timofte authored 6 days ago
1722
      <section class="page" id="page-ca" data-page="ca" hidden>
1723
        <section class="panel">
1724
          <div class="panel-head">
1725
            <h2>Local Certificate Authority</h2>
1726
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
1727
          </div>
1728
          <div class="problems" id="ca-status"></div>
1729
        </section>
1730
        <section class="panel">
1731
          <div class="panel-head">
1732
            <h2>Issued Certificates</h2>
1733
            <div class="stats" id="ca-certs-summary"></div>
1734
          </div>
1735
          <div class="table-wrap">
1736
            <table>
1737
              <thead>
1738
                <tr>
1739
                  <th style="width: 150px">Name</th>
1740
                  <th>DNS names</th>
1741
                  <th style="width: 210px">Validity</th>
1742
                  <th style="width: 180px">Serial</th>
1743
                  <th>Fingerprint</th>
1744
                  <th style="width: 110px">Download</th>
1745
                </tr>
1746
              </thead>
1747
              <tbody id="ca-certs"></tbody>
1748
            </table>
1749
          </div>
1750
        </section>
Xdev Host Manager authored a week ago
1751
      </section>
Bogdan Timofte authored 6 days ago
1752
    </main>
Xdev Host Manager authored a week ago
1753

            
Bogdan Timofte authored 6 days ago
1754
    <div id="host-modal" class="modal-backdrop" hidden>
1755
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
1756
        <div class="modal-head">
1757
          <h2 id="host-modal-title">Edit host</h2>
1758
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
1759
        </div>
1760
        <form id="host-form" class="grid">
1761
          <label>ID<input name="id" required></label>
1762
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
1763
          <label>hosts_ip<input name="hosts_ip" required></label>
1764
          <label>dns_ip<input name="dns_ip" required></label>
1765
          <label class="span2">Names<textarea name="names" required></textarea></label>
1766
          <label>Roles<input name="roles"></label>
1767
          <label>Sources<input name="sources"></label>
1768
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
1769
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 6 days ago
1770
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 6 days ago
1771
          <div class="span2 form-actions">
Bogdan Timofte authored 6 days ago
1772
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
1773
            <button class="danger" type="button" id="delete-host">Delete host</button>
1774
          </div>
1775
        </form>
1776
      </section>
Bogdan Timofte authored 6 days ago
1777
    </div>
Xdev Host Manager authored a week ago
1778
  </div>
1779

            
Bogdan Timofte authored 5 days ago
1780
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
1781
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
1782
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
1783
  </div>
Bogdan Timofte authored 6 days ago
1784

            
Xdev Host Manager authored a week ago
1785
  <script>
Xdev Host Manager authored a week ago
1786
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
Bogdan Timofte authored 6 days ago
1787
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
1788

            
1789
    const $ = (id) => document.getElementById(id);
1790
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 6 days ago
1791
    const PAGE_PATHS = {
1792
      '/': 'overview',
1793
      '/overview': 'overview',
1794
      '/hosts': 'hosts',
1795
      '/dns': 'dns',
1796
      '/work-orders': 'work-orders',
1797
      '/ca': 'ca',
1798
    };
Xdev Host Manager authored a week ago
1799

            
1800
    async function api(path, options = {}) {
1801
      const res = await fetch(path, options);
1802
      const body = await res.json();
1803
      if (!res.ok) throw new Error(body.error || res.statusText);
1804
      return body;
1805
    }
1806

            
Bogdan Timofte authored 6 days ago
1807
    function currentPage() {
1808
      return PAGE_PATHS[window.location.pathname] || 'overview';
1809
    }
1810

            
1811
    function showPage(page, push = false) {
1812
      const target = page || 'overview';
1813
      document.querySelectorAll('[data-page]').forEach(section => {
1814
        section.hidden = section.dataset.page !== target;
1815
      });
1816
      document.querySelectorAll('[data-page-link]').forEach(link => {
1817
        link.classList.toggle('active', link.dataset.pageLink === target);
1818
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
1819
      });
1820
      if (push) {
1821
        const href = target === 'overview' ? '/overview' : '/' + target;
1822
        history.pushState({ page: target }, '', href);
1823
      }
1824
    }
1825

            
Xdev Host Manager authored a week ago
1826
    function showLogin(errorText) {
Bogdan Timofte authored 6 days ago
1827
      document.body.classList.remove('is-app');
1828
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
1829
      $('app').style.display = 'none';
1830
      $('login-screen').style.display = 'flex';
1831
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
1832
      clearOtp();
Xdev Host Manager authored a week ago
1833
    }
1834

            
1835
    function showApp() {
Bogdan Timofte authored 6 days ago
1836
      document.body.classList.remove('is-login');
1837
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
1838
      $('login-screen').style.display = 'none';
1839
      $('app').style.display = 'block';
Bogdan Timofte authored 6 days ago
1840
      showPage(currentPage());
Xdev Host Manager authored a week ago
1841
    }
1842

            
Xdev Host Manager authored a week ago
1843
    async function refresh() {
1844
      const session = await api('/api/session');
1845
      state.authenticated = session.authenticated;
Xdev Host Manager authored a week ago
1846
      if (!state.authenticated) { showLogin(); return; }
1847
      showApp();
Xdev Host Manager authored a week ago
1848
      const data = await api('/api/hosts');
1849
      state.hosts = data.hosts || [];
1850
      state.problems = data.problems || [];
1851
      render(data);
Xdev Host Manager authored a week ago
1852
      await renderCa();
Xdev Host Manager authored a week ago
1853
      await renderWorkOrders();
Xdev Host Manager authored a week ago
1854
    }
1855

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

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

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

            
1868
      renderHosts();
1869
    }
1870

            
Xdev Host Manager authored a week ago
1871
    async function renderCa() {
1872
      try {
1873
        const status = await api('/api/ca/status');
1874
        if (!status.initialized) {
1875
          $('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 6 days ago
1876
          $('ca-certs-summary').innerHTML = '';
1877
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
1878
          return;
1879
        }
1880
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 6 days ago
1881
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
1882
        $('ca-status').innerHTML = `
Bogdan Timofte authored 6 days ago
1883
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
1884
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 6 days ago
1885
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
1886
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 6 days ago
1887
            <div>
1888
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
1889
              <span>${certs.length} issued certificate(s)</span>
1890
            </div>
Xdev Host Manager authored a week ago
1891
          </div>`;
Bogdan Timofte authored 6 days ago
1892
        $('ca-certs-summary').innerHTML = [
1893
          ['issued', certs.length],
1894
          ['expiring', certs.filter(cert => {
1895
            const days = daysUntil(cert.not_after);
1896
            return days !== null && days >= 0 && days <= 30;
1897
          }).length],
1898
          ['expired', certs.filter(cert => {
1899
            const days = daysUntil(cert.not_after);
1900
            return days !== null && days < 0;
1901
          }).length],
1902
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1903
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
1904
          const days = daysUntil(cert.not_after);
1905
          const dnsNames = cert.dns_names || [];
1906
          const dnsHtml = dnsNames.length
1907
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
1908
            : '<span class="muted">No DNS SANs reported.</span>';
1909
          return `<tr>
1910
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
1911
            <td>${dnsHtml}</td>
1912
            <td>
1913
              <div class="ca-detail">
1914
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
1915
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
1916
              </div>
1917
            </td>
1918
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
1919
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
1920
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
1921
          </tr>`;
1922
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
1923
      } catch (e) {
1924
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 6 days ago
1925
        $('ca-certs-summary').innerHTML = '';
1926
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
1927
      }
1928
    }
1929

            
Bogdan Timofte authored 6 days ago
1930
    function daysUntil(dateText) {
1931
      const time = Date.parse(dateText || '');
1932
      if (!Number.isFinite(time)) return null;
1933
      return Math.ceil((time - Date.now()) / 86400000);
1934
    }
1935

            
1936
    function certStatusClass(days) {
1937
      if (days === null) return '';
1938
      if (days < 0) return 'bad';
1939
      if (days <= 30) return 'warn';
1940
      return 'ok';
1941
    }
1942

            
1943
    function certStatusLabel(days) {
1944
      if (days === null) return 'validity unknown';
1945
      if (days < 0) return 'expired';
1946
      if (days === 0) return 'expires today';
1947
      return `${days}d remaining`;
1948
    }
1949

            
Xdev Host Manager authored a week ago
1950
    async function renderWorkOrders() {
1951
      try {
1952
        const data = await api('/api/work-orders');
1953
        state.workOrders = data.work_orders || [];
1954
        $('wo-stats').innerHTML = [
1955
          ['pending', data.counts.pending],
1956
          ['total', data.counts.work_orders],
1957
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1958

            
1959
        if (!state.workOrders.length) {
1960
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
1961
          return;
1962
        }
1963

            
1964
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
1965
          const checklist = wo.checklist || [];
1966
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
1967
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
1968
          const checklistHtml = checklist.map(item => {
1969
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
1970
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
1971
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
1972
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
1973
            </label>`;
1974
          }).join('');
Xdev Host Manager authored a week ago
1975
          const actions = (wo.actions || []).map(a => {
1976
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
1977
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
1978
          }).join('');
1979
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
1980
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
1981
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
1982
            : '';
Bogdan Timofte authored 6 days ago
1983
          return `<div class="problem work-order-card">
1984
            <div class="work-order-head">
Xdev Host Manager authored a week ago
1985
              <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
1986
              ${button}
1987
            </div>
Bogdan Timofte authored 6 days ago
1988
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
1989
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
1990
            <div class="work-order-checklist">${checklistHtml}</div>
1991
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
1992
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
1993
          </div>`;
1994
        }).join('');
Xdev Host Manager authored a week ago
1995
        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
1996
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
1997
      } catch (e) {
1998
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
1999
      }
2000
    }
2001

            
Xdev Host Manager authored a week ago
2002
    async function updateWorkOrderChecklist(id, itemId, checked) {
2003
      try {
2004
        await api('/api/work-orders/checklist', {
2005
          method: 'POST',
2006
          headers: { 'Content-Type': 'application/json' },
2007
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
2008
        });
2009
        msg('work order updated');
2010
        await refresh();
2011
      } catch (e) { msg(e.message); await refresh(); }
2012
    }
2013

            
Xdev Host Manager authored a week ago
2014
    async function confirmWorkOrder(id) {
2015
      const typed = prompt(`Type ${id} to confirm this work order`);
2016
      if (typed !== id) return;
2017
      try {
2018
        await api('/api/work-orders/confirm', {
2019
          method: 'POST',
2020
          headers: { 'Content-Type': 'application/json' },
2021
          body: JSON.stringify({ id, confirm: typed })
2022
        });
2023
        msg('work order confirmed; local-hosts.tsv written');
2024
        await refresh();
2025
      } catch (e) { msg(e.message); }
2026
    }
2027

            
Xdev Host Manager authored a week ago
2028
    function renderHosts() {
2029
      const filter = $('filter').value.toLowerCase();
2030
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
2031
        .slice()
2032
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
2033
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
2034
        .map(h => {
2035
          const problems = state.problems.filter(p => p.host_id === h.id);
2036
          const cls = problems.length ? 'warn' : 'ok';
2037
          return `<tr data-id="${escapeHtml(h.id)}">
2038
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
2039
            <td>${escapeHtml(h.hosts_ip || '')}</td>
2040
            <td>${escapeHtml(h.dns_ip || '')}</td>
Bogdan Timofte authored 5 days ago
2041
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
2042
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
2043
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
2044
            <td>${escapeHtml(h.status || '')}</td>
2045
          </tr>`;
2046
        }).join('');
2047
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
2048
    }
2049

            
Bogdan Timofte authored 5 days ago
2050
    function renderNamePills(host) {
2051
      const declared = host.declared_names || host.names || [];
2052
      const derived = host.derived_names || [];
2053
      const declaredHtml = declared.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
2054
      const derivedHtml = derived.map(name => `<span class="pill derived" title="derived from madagascar.xdev.ro">${escapeHtml(name)}</span>`).join('');
2055
      return declaredHtml + derivedHtml;
2056
    }
2057

            
Xdev Host Manager authored a week ago
2058
    function editHost(id) {
2059
      const host = state.hosts.find(h => h.id === id);
2060
      if (!host) return;
2061
      const form = $('host-form');
Bogdan Timofte authored 6 days ago
2062
      clearHostFormMessage();
2063
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
Bogdan Timofte authored 5 days ago
2064
      hostField('names').value = (host.declared_names || host.names || []).join('\n');
Bogdan Timofte authored 6 days ago
2065
      hostField('roles').value = (host.roles || []).join(' ');
2066
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 6 days ago
2067
      openHostModal('Edit host');
2068
    }
2069

            
2070
    function newHost() {
2071
      const form = $('host-form');
2072
      form.reset();
Bogdan Timofte authored 6 days ago
2073
      clearHostFormMessage();
2074
      hostField('status').value = 'active';
2075
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 6 days ago
2076
      openHostModal('New host');
2077
    }
2078

            
2079
    function openHostModal(title) {
2080
      $('host-modal-title').textContent = title || 'Edit host';
2081
      $('host-modal').hidden = false;
2082
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 6 days ago
2083
      hostFormSnapshot = hostFormState();
2084
      hostField('id').focus();
2085
    }
2086

            
2087
    function requestCloseHostModal() {
2088
      if ($('save-host').disabled) return;
2089
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
2090
      closeHostModal();
Bogdan Timofte authored 6 days ago
2091
    }
2092

            
2093
    function closeHostModal() {
2094
      $('host-modal').hidden = true;
2095
      document.body.style.overflow = '';
Bogdan Timofte authored 6 days ago
2096
      setHostFormBusy(false);
2097
      clearHostFormMessage();
2098
      hostFormSnapshot = '';
2099
    }
2100

            
2101
    function hostField(name) {
2102
      return $('host-form').elements.namedItem(name);
2103
    }
2104

            
2105
    function hostFormState() {
2106
      return JSON.stringify(formObject($('host-form')));
2107
    }
2108

            
2109
    function hostFormDirty() {
2110
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
2111
    }
2112

            
2113
    function setHostFormBusy(busy) {
2114
      $('save-host').disabled = busy;
2115
      $('delete-host').disabled = busy;
2116
      $('close-host-modal').disabled = busy;
2117
    }
2118

            
2119
    function setHostFormMessage(text, isError = false) {
2120
      const message = $('host-form-message');
2121
      message.textContent = text || '';
2122
      message.classList.toggle('error', !!isError);
2123
    }
2124

            
2125
    function clearHostFormMessage() {
2126
      setHostFormMessage('');
Xdev Host Manager authored a week ago
2127
    }
2128

            
2129
    function formObject(form) {
2130
      return Object.fromEntries(new FormData(form).entries());
2131
    }
2132

            
2133
    function escapeHtml(value) {
Bogdan Timofte authored 6 days ago
2134
      value = value == null ? '' : String(value);
Xdev Host Manager authored a week ago
2135
      return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
2136
    }
2137

            
Bogdan Timofte authored 6 days ago
2138
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
2139

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

            
2145
    if (loginAccount) {
2146
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
2147
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
2148
      loginAccount.addEventListener('input', () => {
2149
        const value = (loginAccount.value || '').trim();
2150
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
2151
      });
2152
    }
2153

            
Xdev Host Manager authored a week ago
2154
    function setOtpDigit(idx, value) {
2155
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
2156
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
2157
      otpDigits[idx].classList.toggle('filled', !!digit);
2158
    }
2159

            
Bogdan Timofte authored 5 days ago
2160
    // Move focus to the next empty box: forward from idx, then wrapping to the
2161
    // start. This lets out-of-order entry continue (e.g. after the last box,
2162
    // jump back to the first still-empty box). Stays put when all boxes are full.
2163
    function advanceFocus(idx) {
2164
      for (let i = idx + 1; i < otpDigits.length; i++) {
2165
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
2166
      }
2167
      for (let i = 0; i <= idx; i++) {
2168
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
2169
      }
2170
    }
2171

            
Bogdan Timofte authored 5 days ago
2172
    // Spread multiple digits across boxes starting at startIdx. Used for paste
2173
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
2174
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
2175
      const digits = (text || '').replace(/\D/g, '').split('');
2176
      if (!digits.length) return;
2177
      let last = startIdx;
2178
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
2179
        last = startIdx + i;
2180
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
2181
      }
Bogdan Timofte authored 5 days ago
2182
      syncOtpFields();
Bogdan Timofte authored 5 days ago
2183
      advanceFocus(last);
Xdev Host Manager authored a week ago
2184
      maybeSubmitOtp();
2185
    }
2186

            
Bogdan Timofte authored 5 days ago
2187
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
2188
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
2189
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
2190
    function maybeSubmitOtp() {
2191
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
2192
    }
2193
    function clearOtp() {
Bogdan Timofte authored 5 days ago
2194
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
2195
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 5 days ago
2196
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
2197
      // an unknown operator, so Safari's autofill anchor on the username stays.
2198
      if (loginAccount && !loginAccount.value) loginAccount.focus();
2199
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
2200
    }
2201

            
Bogdan Timofte authored 5 days ago
2202
    otpDigits.forEach((input, idx) => {
2203
      input.addEventListener('input', () => {
Bogdan Timofte authored 5 days ago
2204
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
2205
        // A single box may receive several digits at once (autofill / typing fast).
2206
        if (input.value.replace(/\D/g, '').length > 1) {
2207
          fillOtp(input.value, idx);
2208
          return;
2209
        }
2210
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
2211
        syncOtpFields();
Bogdan Timofte authored 5 days ago
2212
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
2213
        maybeSubmitOtp();
2214
      });
Bogdan Timofte authored 5 days ago
2215

            
2216
      input.addEventListener('paste', (e) => {
2217
        e.preventDefault();
Bogdan Timofte authored 5 days ago
2218
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
2219
        const text = (e.clipboardData || window.clipboardData).getData('text');
2220
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
2221
      });
Bogdan Timofte authored 5 days ago
2222

            
2223
      input.addEventListener('keydown', (e) => {
2224
        if (e.key === 'Backspace') {
2225
          e.preventDefault();
Bogdan Timofte authored 5 days ago
2226
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
2227
          if (input.value) { setOtpDigit(idx, ''); }
2228
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
2229
          syncOtpFields();
2230
        } else if (e.key === 'ArrowLeft' && idx > 0) {
2231
          e.preventDefault();
2232
          otpDigits[idx - 1].focus();
2233
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
2234
          e.preventDefault();
2235
          otpDigits[idx + 1].focus();
2236
        }
2237
      });
2238
    });
2239

            
Bogdan Timofte authored 5 days ago
2240
    // Focus the first OTP box only for a returning operator (username known).
2241
    // For an unknown operator, leave focus on the username field so Safari can
2242
    // present its OTP autofill anchored there without being dismissed by a focus
2243
    // change (pbx-admin pattern).
2244
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
2245
    else if (loginAccount) loginAccount.focus();
2246
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
2247

            
Bogdan Timofte authored 6 days ago
2248
    document.querySelectorAll('[data-page-link]').forEach(link => {
2249
      link.addEventListener('click', (event) => {
2250
        event.preventDefault();
2251
        showPage(link.dataset.pageLink, true);
2252
      });
2253
    });
2254

            
2255
    window.addEventListener('popstate', () => showPage(currentPage()));
2256

            
Bogdan Timofte authored 5 days ago
2257
    async function copyText(text) {
2258
      if (navigator.clipboard && window.isSecureContext) {
2259
        await navigator.clipboard.writeText(text);
2260
        return;
2261
      }
2262
      const input = document.createElement('textarea');
2263
      input.value = text;
2264
      input.setAttribute('readonly', '');
2265
      input.style.position = 'fixed';
2266
      input.style.left = '-10000px';
2267
      document.body.appendChild(input);
2268
      input.select();
2269
      document.execCommand('copy');
2270
      document.body.removeChild(input);
2271
    }
2272

            
2273
    $('copy-build').addEventListener('click', async () => {
2274
      try {
2275
        await copyText($('copy-build').dataset.buildDetails || '');
2276
        if (state.authenticated) msg('build details copied');
2277
      } catch (e) {
2278
        if (state.authenticated) msg('copy failed');
2279
      }
2280
    });
2281

            
Xdev Host Manager authored a week ago
2282
    $('login-form').addEventListener('submit', async (event) => {
2283
      event.preventDefault();
Bogdan Timofte authored 5 days ago
2284
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
2285
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
2286
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
2287
      try {
Xdev Host Manager authored a week ago
2288
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
2289
        await refresh();
Xdev Host Manager authored a week ago
2290
      } catch (e) {
2291
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
2292
      } finally {
Xdev Host Manager authored a week ago
2293
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
2294
      }
Xdev Host Manager authored a week ago
2295
    });
2296

            
2297
    $('logout').addEventListener('click', async () => {
2298
      await api('/api/logout', { method: 'POST' }).catch(() => {});
Bogdan Timofte authored 6 days ago
2299
      window.location.replace('/?logged_out=' + Date.now());
Xdev Host Manager authored a week ago
2300
    });
2301

            
Xdev Host Manager authored a week ago
2302
    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
2303
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 6 days ago
2304
    $('new-host').addEventListener('click', newHost);
Bogdan Timofte authored 6 days ago
2305
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
2306
    $('host-modal').addEventListener('click', (event) => {
2307
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
2308
    });
Bogdan Timofte authored 6 days ago
2309
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 6 days ago
2310
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 6 days ago
2311
    });
Xdev Host Manager authored a week ago
2312

            
Xdev Host Manager authored a week ago
2313
    $('host-form').addEventListener('submit', async (event) => {
2314
      event.preventDefault();
Bogdan Timofte authored 6 days ago
2315
      setHostFormBusy(true);
2316
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
2317
      try {
2318
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 6 days ago
2319
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 6 days ago
2320
        closeHostModal();
Xdev Host Manager authored a week ago
2321
        msg('host saved');
2322
        await refresh();
Bogdan Timofte authored 6 days ago
2323
      } catch (e) {
2324
        setHostFormMessage(e.message, true);
2325
        msg(e.message);
2326
      } finally {
2327
        setHostFormBusy(false);
2328
      }
2329
    });
2330

            
2331
    $('host-form').addEventListener('invalid', (event) => {
2332
      setHostFormMessage('Complete the required host fields before saving.', true);
2333
    }, true);
2334

            
2335
    $('host-form').addEventListener('input', () => {
2336
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
2337
    });
2338

            
2339
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 6 days ago
2340
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
2341
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 6 days ago
2342
      setHostFormBusy(true);
2343
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
2344
      try {
2345
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
2346
        $('host-form').reset();
Bogdan Timofte authored 6 days ago
2347
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 6 days ago
2348
        closeHostModal();
Xdev Host Manager authored a week ago
2349
        msg('host deleted');
2350
        await refresh();
Bogdan Timofte authored 6 days ago
2351
      } catch (e) {
2352
        setHostFormMessage(e.message, true);
2353
        msg(e.message);
2354
      } finally {
2355
        setHostFormBusy(false);
2356
      }
Xdev Host Manager authored a week ago
2357
    });
2358

            
2359
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
2360
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
2361
      try {
2362
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
2363
        msg('local-hosts.tsv written');
2364
      } catch (e) { msg(e.message); }
2365
    });
2366

            
Xdev Host Manager authored a week ago
2367
    refresh().catch(() => showLogin());
Xdev Host Manager authored a week ago
2368
  </script>
2369
</body>
2370
</html>
2371
HTML
Bogdan Timofte authored 6 days ago
2372
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
2373
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 5 days ago
2374
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
2375
    return $html;
Xdev Host Manager authored a week ago
2376
}