LocalAuthority / scripts / host_manager.pl
Newer Older
1551 lines | 57.643kb
Xdev Host Manager authored 2 days 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);
10
use Digest::SHA qw(hmac_sha1 hmac_sha256_hex sha256_hex);
11
use File::Basename qw(dirname);
12
use File::Path qw(make_path);
13
use IO::Socket::INET;
14
use POSIX qw(strftime);
15
use Time::HiRes qw(time);
16

            
17
my $script_dir = dirname(abs_path($0));
18
my $project_dir = dirname($script_dir);
19

            
20
my %opt = (
21
    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
22
    port => $ENV{HOST_MANAGER_PORT} || 8088,
23
    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
24
    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
Xdev Host Manager authored 2 days ago
25
    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
Xdev Host Manager authored 2 days ago
26
);
27

            
28
while (@ARGV) {
29
    my $arg = shift @ARGV;
30
    if ($arg eq '--bind') {
31
        $opt{bind} = shift @ARGV;
32
    } elsif ($arg eq '--port') {
33
        $opt{port} = shift @ARGV;
34
    } elsif ($arg eq '--data') {
35
        $opt{data} = shift @ARGV;
36
    } elsif ($arg eq '--local-hosts-tsv') {
37
        $opt{local_hosts_tsv} = shift @ARGV;
Xdev Host Manager authored 2 days ago
38
    } elsif ($arg eq '--work-orders') {
39
        $opt{work_orders} = shift @ARGV;
Xdev Host Manager authored 2 days ago
40
    } elsif ($arg eq '--help' || $arg eq '-h') {
41
        usage();
42
        exit 0;
43
    } else {
44
        die "Unknown option: $arg\n";
45
    }
46
}
47

            
48
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
49
my %sessions;
50

            
51
my $server = IO::Socket::INET->new(
52
    LocalHost => $opt{bind},
53
    LocalPort => $opt{port},
54
    Proto => 'tcp',
55
    Listen => 10,
56
    ReuseAddr => 1,
57
) or die "Cannot listen on $opt{bind}:$opt{port}: $!\n";
58

            
59
print "host-manager listening on http://$opt{bind}:$opt{port}\n";
60
print "data file: $opt{data}\n";
61
print "OTP login: " . ($ENV{HOST_MANAGER_TOTP_SECRET} ? "enabled\n" : "disabled; set HOST_MANAGER_TOTP_SECRET\n");
62

            
63
while (my $client = $server->accept) {
64
    eval {
65
        $client->autoflush(1);
66
        handle_client($client);
67
    };
68
    if ($@) {
69
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
70
    }
71
    close $client;
72
}
73

            
74
sub usage {
75
    print <<"EOF";
76
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
77

            
78
Environment:
79
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
80
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
81
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
82
  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
Xdev Host Manager authored 2 days ago
83
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Xdev Host Manager authored 2 days ago
84

            
Xdev Host Manager authored 2 days ago
85
The nginx vhost keeps registry, CA, work order and download endpoints behind OTP.
Xdev Host Manager authored 2 days ago
86
EOF
87
}
88

            
89
sub handle_client {
90
    my ($client) = @_;
91
    my $request_line = <$client>;
92
    return unless defined $request_line;
93
    $request_line =~ s/\r?\n$//;
94
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
95
    return send_text($client, 400, 'bad request') unless $method && $target;
96

            
97
    my %headers;
98
    while (my $line = <$client>) {
99
        $line =~ s/\r?\n$//;
100
        last if $line eq '';
101
        my ($k, $v) = split /:\s*/, $line, 2;
102
        $headers{lc $k} = $v if defined $k && defined $v;
103
    }
104

            
105
    my $body = '';
106
    if (($headers{'content-length'} || 0) > 0) {
107
        read($client, $body, int($headers{'content-length'}));
108
    }
109

            
110
    my ($path, $query) = split /\?/, $target, 2;
111
    my %query = parse_params($query || '');
112

            
113
    if ($method eq 'GET' && $path eq '/') {
114
        return send_html($client, 200, app_html());
115
    }
116
    if ($method eq 'GET' && $path eq '/healthz') {
Xdev Host Manager authored 2 days ago
117
        return send_json($client, 200, { ok => json_bool(1) });
Xdev Host Manager authored 2 days ago
118
    }
119
    if ($method eq 'GET' && $path eq '/api/session') {
120
        return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
121
    }
Xdev Host Manager authored 2 days ago
122
    if ($method eq 'POST' && $path eq '/api/login') {
123
        return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
124
        my $payload = request_payload(\%headers, $body);
125
        my $otp = $payload->{otp} || '';
126
        if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
127
            return send_json($client, 401, { error => 'invalid_otp' });
128
        }
129
        my $token = create_session();
130
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
131
    }
132
    if ($method eq 'POST' && $path eq '/api/logout') {
133
        expire_session(\%headers);
134
        return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
135
    }
136

            
137
    return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
138

            
Xdev Host Manager authored 2 days ago
139
    if ($method eq 'GET' && $path eq '/api/hosts') {
140
        my $registry = load_registry();
141
        return send_json($client, 200, registry_payload($registry));
142
    }
Xdev Host Manager authored 2 days ago
143
    if ($method eq 'GET' && $path eq '/api/work-orders') {
144
        return send_json($client, 200, work_orders_payload(load_work_orders()));
145
    }
Xdev Host Manager authored 2 days ago
146
    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
147
        return send_file($client, $opt{data}, 'application/x-yaml; charset=utf-8', 'hosts.yaml');
148
    }
149
    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
150
        my $registry = load_registry();
151
        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
152
    }
153
    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
154
        my $registry = load_registry();
155
        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
156
    }
Xdev Host Manager authored 2 days ago
157
    if ($method eq 'GET' && $path eq '/api/ca/status') {
158
        return send_json_raw($client, 200, ca_manager_json('status-json'));
159
    }
160
    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
161
        return send_json_raw($client, 200, ca_manager_json('list-json'));
162
    }
163
    if ($method eq 'GET' && $path eq '/download/ca.crt') {
164
        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
165
    }
Xdev Host Manager authored 2 days ago
166

            
167
    if ($method eq 'POST' && $path =~ m{^/api/}) {
168
        if ($path eq '/api/hosts/upsert') {
169
            my $payload = request_payload(\%headers, $body);
170
            return upsert_host($client, $payload);
171
        }
172
        if ($path eq '/api/hosts/delete') {
173
            my $payload = request_payload(\%headers, $body);
174
            return delete_host($client, $payload->{id} || '');
175
        }
Xdev Host Manager authored 2 days ago
176
        if ($path eq '/api/work-orders/confirm') {
177
            my $payload = request_payload(\%headers, $body);
178
            return confirm_work_order($client, $payload);
179
        }
Xdev Host Manager authored 2 days ago
180
        if ($path eq '/api/render/local-hosts-tsv') {
181
            my $registry = load_registry();
182
            my $content = render_local_hosts_tsv($registry);
183
            backup_file($opt{local_hosts_tsv});
184
            write_file($opt{local_hosts_tsv}, $content);
185
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
186
        }
187
    }
188

            
189
    return send_json($client, 404, { error => 'not_found' });
190
}
191

            
192
sub load_registry {
193
    return parse_hosts_yaml(read_file($opt{data}));
194
}
195

            
196
sub save_registry {
197
    my ($registry) = @_;
198
    $registry->{updated_at} = iso_now();
199
    backup_file($opt{data});
200
    write_file($opt{data}, render_hosts_yaml($registry));
201
}
202

            
Xdev Host Manager authored 2 days ago
203
sub load_work_orders {
204
    return { version => 1, work_orders => [] } unless -f $opt{work_orders};
205
    return parse_work_orders_yaml(read_file($opt{work_orders}));
206
}
207

            
208
sub save_work_orders {
209
    my ($orders) = @_;
210
    backup_file($opt{work_orders});
211
    write_file($opt{work_orders}, render_work_orders_yaml($orders));
212
}
213

            
214
sub work_orders_payload {
215
    my ($orders) = @_;
216
    my $pending = 0;
217
    for my $wo (@{ $orders->{work_orders} || [] }) {
218
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
219
    }
220
    return {
221
        version => $orders->{version},
222
        work_orders => $orders->{work_orders} || [],
223
        counts => {
224
            work_orders => scalar @{ $orders->{work_orders} || [] },
225
            pending => $pending,
226
        },
227
    };
228
}
229

            
230
sub confirm_work_order {
231
    my ($client, $payload) = @_;
232
    my $id = clean_scalar($payload->{id} || '');
233
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
234
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
235

            
236
    my $orders = load_work_orders();
237
    my $work_order;
238
    for my $wo (@{ $orders->{work_orders} || [] }) {
239
        if (($wo->{id} || '') eq $id) {
240
            $work_order = $wo;
241
            last;
242
        }
243
    }
244
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
245
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
246

            
247
    my $registry = load_registry();
248
    my $results = apply_work_order($registry, $work_order);
249
    $work_order->{status} = 'confirmed';
250
    $work_order->{confirmed_at} = iso_now();
251
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
252

            
253
    save_registry($registry);
254
    save_work_orders($orders);
255
    backup_file($opt{local_hosts_tsv});
256
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
257

            
258
    return send_json($client, 200, {
259
        ok => json_bool(1),
260
        work_order => $work_order,
261
        results => $results,
262
        local_hosts_tsv => $opt{local_hosts_tsv},
263
    });
264
}
265

            
266
sub apply_work_order {
267
    my ($registry, $work_order) = @_;
268
    my @results;
269
    for my $action (@{ $work_order->{actions} || [] }) {
270
        my $type = $action->{type} || '';
271
        if ($type eq 'remove_name') {
272
            my $host_id = $action->{host_id} || '';
273
            my $name = $action->{name} || '';
274
            my $removed = 0;
275
            for my $host (@{ $registry->{hosts} || [] }) {
276
                next unless ($host->{id} || '') eq $host_id;
277
                my @kept = grep { $_ ne $name } @{ $host->{names} || [] };
278
                $removed = @kept != @{ $host->{names} || [] };
279
                $host->{names} = \@kept;
280
                last;
281
            }
282
            push @results, {
283
                type => $type,
284
                host_id => $host_id,
285
                name => $name,
286
                removed => json_bool($removed),
287
            };
288
        } else {
289
            die "Unsupported work order action: $type\n";
290
        }
291
    }
292
    return \@results;
293
}
294

            
Xdev Host Manager authored 2 days ago
295
sub registry_payload {
296
    my ($registry) = @_;
297
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored 2 days ago
298
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Xdev Host Manager authored 2 days ago
299
    return {
300
        version => $registry->{version},
301
        updated_at => $registry->{updated_at},
302
        policy => $registry->{policy},
Xdev Host Manager authored 2 days ago
303
        hosts => \@hosts,
Xdev Host Manager authored 2 days ago
304
        problems => $problems,
305
        counts => {
306
            hosts => scalar @{ $registry->{hosts} },
307
            problems => scalar @$problems,
308
        },
309
    };
310
}
311

            
312
sub upsert_host {
313
    my ($client, $payload) = @_;
314
    my $id = clean_id($payload->{id} || '');
315
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
316

            
317
    my $hosts_ip = clean_scalar($payload->{hosts_ip} || '');
318
    my $dns_ip = clean_scalar($payload->{dns_ip} || '');
319
    return send_json($client, 400, { error => 'missing_ip' }) unless $hosts_ip && $dns_ip;
320

            
Xdev Host Manager authored 2 days ago
321
    my @names = remove_derived_names(clean_list($payload->{names}));
Xdev Host Manager authored 2 days ago
322
    return send_json($client, 400, { error => 'missing_names' }) unless @names;
323

            
324
    my $registry = load_registry();
325
    my %host = (
326
        id => $id,
327
        status => clean_scalar($payload->{status} || 'active'),
328
        hosts_ip => $hosts_ip,
329
        dns_ip => $dns_ip,
330
        names => \@names,
331
        roles => [ clean_list($payload->{roles}) ],
332
        sources => [ clean_list($payload->{sources}) ],
333
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
334
        notes => clean_scalar($payload->{notes} || ''),
335
    );
336

            
337
    my $replaced = 0;
338
    for my $i (0 .. $#{ $registry->{hosts} }) {
339
        if ($registry->{hosts}->[$i]{id} eq $id) {
340
            $registry->{hosts}->[$i] = \%host;
341
            $replaced = 1;
342
            last;
343
        }
344
    }
345
    push @{ $registry->{hosts} }, \%host unless $replaced;
346
    save_registry($registry);
347
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
348
}
349

            
350
sub delete_host {
351
    my ($client, $id) = @_;
352
    $id = clean_id($id);
353
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
354

            
355
    my $registry = load_registry();
356
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
357
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
358
    $registry->{hosts} = \@kept;
359
    save_registry($registry);
360
    return send_json($client, 200, { ok => json_bool(1) });
361
}
362

            
363
sub analyze_hosts {
364
    my ($hosts) = @_;
365
    my @problems;
366
    my (%names, %ids);
367
    for my $host (@$hosts) {
368
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
369
        my @fqdn = grep { /\.madagascar\.xdev\.ro$/ } @{ $host->{names} || [] };
370
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless @fqdn || ($host->{status} || '') ne 'active';
371
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
372
            if grep { /\.vad\.is\.xdev\.ro$/ } @{ $host->{names} || [] };
373
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
374
            if grep { /^(is|vad|b)-/ } @{ $host->{names} || [] };
375
        for my $name (@{ $host->{names} || [] }) {
376
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
377
        }
Xdev Host Manager authored 2 days ago
378
        my %declared = map { $_ => 1 } @{ $host->{names} || [] };
379
        for my $derived (derived_names($host)) {
380
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
381
                if $declared{$derived};
382
        }
Xdev Host Manager authored 2 days ago
383
        if (($host->{hosts_ip} || '') ne ($host->{dns_ip} || '') && ($host->{hosts_ip} || '') ne '127.0.0.1') {
384
            push @problems, problem($host, 'split-ip', 'hosts_ip differs from dns_ip; check that this is intentional');
385
        }
386
    }
387
    return \@problems;
388
}
389

            
Xdev Host Manager authored 2 days ago
390
sub host_payload {
391
    my ($host) = @_;
392
    my %copy = %$host;
393
    $copy{names} = [ effective_names($host) ];
394
    $copy{declared_names} = [ @{ $host->{names} || [] } ];
395
    $copy{derived_names} = [ derived_names($host) ];
396
    return \%copy;
397
}
398

            
399
sub effective_names {
400
    my ($host) = @_;
401
    my @names = @{ $host->{names} || [] };
402
    push @names, derived_names($host);
403
    return unique_preserve(@names);
404
}
405

            
406
sub derived_names {
407
    my ($host) = @_;
408
    my @derived;
409
    for my $name (@{ $host->{names} || [] }) {
410
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
411
        push @derived, $1 if length $1;
412
    }
413
    return unique_preserve(@derived);
414
}
415

            
416
sub remove_derived_names {
417
    my @names = @_;
418
    my %derived;
419
    for my $name (@names) {
420
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
421
        $derived{$1} = 1;
422
    }
423
    return grep { !$derived{$_} } @names;
424
}
425

            
426
sub unique_preserve {
427
    my @values = @_;
428
    my %seen;
429
    return grep { !$seen{$_}++ } @values;
430
}
431

            
Xdev Host Manager authored 2 days ago
432
sub problem {
433
    my ($host, $code, $message) = @_;
434
    return { host_id => $host->{id}, code => $code, message => $message };
435
}
436

            
437
sub render_local_hosts_tsv {
438
    my ($registry) = @_;
439
    my $out = "# Local DNS manifest for the madagascar network.\n";
440
    $out .= "# Generated by scripts/host_manager.pl from config/hosts.yaml.\n";
441
    $out .= "#\n";
442
    $out .= "# Format:\n";
443
    $out .= "# hosts_ip<TAB>dns_ip<TAB>name [aliases...]\n";
444
    $out .= "#\n";
445
    $out .= "# Priority rule:\n";
446
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
447
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
448
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
449
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
450
        next unless ($host->{status} || 'active') eq 'active';
Xdev Host Manager authored 2 days ago
451
        my @names = effective_names($host);
452
        next unless @names;
453
        $out .= join("\t", $host->{hosts_ip}, $host->{dns_ip}, join(' ', @names)) . "\n";
Xdev Host Manager authored 2 days ago
454
    }
455
    return $out;
456
}
457

            
458
sub render_monitoring {
459
    my ($registry) = @_;
460
    my @hosts;
461
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
462
        next unless ($host->{status} || 'active') eq 'active';
463
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored 2 days ago
464
        my @names = effective_names($host);
Xdev Host Manager authored 2 days ago
465
        push @hosts, {
466
            id => $host->{id},
Xdev Host Manager authored 2 days ago
467
            primary_name => $names[0],
Xdev Host Manager authored 2 days ago
468
            address => $host->{dns_ip},
Xdev Host Manager authored 2 days ago
469
            aliases => \@names,
470
            declared_names => [ @{ $host->{names} || [] } ],
471
            derived_names => [ derived_names($host) ],
Xdev Host Manager authored 2 days ago
472
            roles => [ @{ $host->{roles} || [] } ],
473
            monitoring => $host->{monitoring} || 'pending',
474
            notes => $host->{notes} || '',
475
        };
476
    }
477
    return {
478
        version => $registry->{version},
479
        generated_at => iso_now(),
480
        source => 'config/hosts.yaml',
481
        hosts => \@hosts,
482
    };
483
}
484

            
Xdev Host Manager authored 2 days ago
485
sub ca_script_path {
486
    return "$project_dir/scripts/ca_manager.sh";
487
}
488

            
489
sub ca_dir {
490
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
491
}
492

            
493
sub ca_cert_path {
494
    return ca_dir() . "/certs/ca.cert.pem";
495
}
496

            
497
sub ca_manager_json {
498
    my ($command) = @_;
499
    my $script = ca_script_path();
500
    die "CA manager script is missing\n" unless -x $script;
501
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
502
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
503
    local $/;
504
    my $out = <$fh>;
505
    close $fh or die "CA manager failed\n";
506
    return $out || '{}';
507
}
508

            
Xdev Host Manager authored 2 days ago
509
sub parse_hosts_yaml {
510
    my ($text) = @_;
511
    my %registry = (
512
        version => 1,
513
        updated_at => '',
514
        policy => {},
515
        hosts => [],
516
    );
517
    my ($section, $current, $list_key);
518
    for my $line (split /\n/, $text) {
519
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
520
        if ($line =~ /^version:\s*(\d+)/) {
521
            $registry{version} = int($1);
522
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
523
            $registry{updated_at} = yaml_unquote($1);
524
        } elsif ($line =~ /^policy:\s*$/) {
525
            $section = 'policy';
526
        } elsif ($line =~ /^hosts:\s*$/) {
527
            $section = 'hosts';
528
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
529
            $registry{policy}{$1} = yaml_unquote($2);
530
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
531
            $current = {
532
                id => yaml_unquote($1),
533
                status => 'active',
534
                hosts_ip => '',
535
                dns_ip => '',
536
                names => [],
537
                roles => [],
538
                sources => [],
539
                monitoring => 'pending',
540
                notes => '',
541
            };
542
            push @{ $registry{hosts} }, $current;
543
            $list_key = undef;
544
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
545
            $list_key = $1;
546
            $current->{$list_key} ||= [];
547
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
548
            push @{ $current->{$list_key} }, yaml_unquote($1);
549
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
550
            $current->{$1} = yaml_unquote($2);
551
            $list_key = undef;
552
        }
553
    }
554
    return \%registry;
555
}
556

            
557
sub render_hosts_yaml {
558
    my ($registry) = @_;
559
    my $out = "version: " . int($registry->{version} || 1) . "\n";
560
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
561
    $out .= "policy:\n";
562
    for my $key (sort keys %{ $registry->{policy} || {} }) {
563
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
564
    }
565
    $out .= "hosts:\n";
566
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
567
        $out .= "  - id: " . yq($host->{id}) . "\n";
568
        for my $key (qw(status hosts_ip dns_ip)) {
569
            $out .= "    $key: " . yq($host->{$key} || '') . "\n";
570
        }
571
        for my $key (qw(names roles sources)) {
572
            $out .= "    $key:\n";
573
            for my $value (@{ $host->{$key} || [] }) {
574
                $out .= "      - " . yq($value) . "\n";
575
            }
576
        }
577
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
578
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
579
    }
580
    return $out;
581
}
582

            
Xdev Host Manager authored 2 days ago
583
sub parse_work_orders_yaml {
584
    my ($text) = @_;
585
    my %orders = (
586
        version => 1,
587
        work_orders => [],
588
    );
589
    my ($section, $current, $in_actions, $current_action);
590
    for my $line (split /\n/, $text) {
591
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
592
        if ($line =~ /^version:\s*(\d+)/) {
593
            $orders{version} = int($1);
594
        } elsif ($line =~ /^work_orders:\s*$/) {
595
            $section = 'work_orders';
596
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
597
            $current = {
598
                id => yaml_unquote($1),
599
                status => 'pending',
600
                actions => [],
601
            };
602
            push @{ $orders{work_orders} }, $current;
603
            $in_actions = 0;
604
            $current_action = undef;
605
        } elsif ($current && $line =~ /^    actions:\s*$/) {
606
            $in_actions = 1;
607
            $current->{actions} ||= [];
608
        } elsif ($current && $in_actions && $line =~ /^      - type:\s*(.+)$/) {
609
            $current_action = { type => yaml_unquote($1) };
610
            push @{ $current->{actions} }, $current_action;
611
        } elsif ($current_action && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
612
            $current_action->{$1} = yaml_unquote($2);
613
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
614
            $current->{$1} = yaml_unquote($2);
615
            $in_actions = 0;
616
            $current_action = undef;
617
        }
618
    }
619
    return \%orders;
620
}
621

            
622
sub render_work_orders_yaml {
623
    my ($orders) = @_;
624
    my $out = "version: " . int($orders->{version} || 1) . "\n";
625
    $out .= "work_orders:\n";
626
    for my $wo (@{ $orders->{work_orders} || [] }) {
627
        $out .= "  - id: " . yq($wo->{id}) . "\n";
628
        for my $key (qw(status title reason created_at confirmed_at result)) {
629
            next unless exists $wo->{$key} && length($wo->{$key} || '');
630
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
631
        }
632
        $out .= "    actions:\n";
633
        for my $action (@{ $wo->{actions} || [] }) {
634
            $out .= "      - type: " . yq($action->{type}) . "\n";
635
            for my $key (qw(host_id name)) {
636
                next unless exists $action->{$key} && length($action->{$key} || '');
637
                $out .= "        $key: " . yq($action->{$key}) . "\n";
638
            }
639
        }
640
    }
641
    return $out;
642
}
643

            
Xdev Host Manager authored 2 days ago
644
sub request_payload {
645
    my ($headers, $body) = @_;
646
    my $type = $headers->{'content-type'} || '';
647
    if ($type =~ m{application/json}) {
648
        return json_decode($body || '{}');
649
    }
650
    return { parse_params($body || '') };
651
}
652

            
653
sub json_bool {
654
    my ($value) = @_;
655
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
656
}
657

            
658
sub json_encode {
659
    my ($value) = @_;
660
    if (!defined $value) {
661
        return 'null';
662
    }
663
    my $ref = ref($value);
664
    if (!$ref) {
665
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
666
        return json_string($value);
667
    }
668
    if ($ref eq 'HostManager::JSONBool') {
669
        return $$value ? 'true' : 'false';
670
    }
671
    if ($ref eq 'ARRAY') {
672
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
673
    }
674
    if ($ref eq 'HASH') {
675
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
676
    }
677
    return json_string("$value");
678
}
679

            
680
sub json_string {
681
    my ($value) = @_;
682
    $value = '' unless defined $value;
683
    $value =~ s/\\/\\\\/g;
684
    $value =~ s/"/\\"/g;
685
    $value =~ s/\n/\\n/g;
686
    $value =~ s/\r/\\r/g;
687
    $value =~ s/\t/\\t/g;
688
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
689
    return qq("$value");
690
}
691

            
692
sub json_decode {
693
    my ($text) = @_;
694
    my $i = 0;
695
    my $len = length($text);
696
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
697

            
698
    $skip_ws = sub {
699
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
700
    };
701

            
702
    $parse_string = sub {
703
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
704
        $i++;
705
        my $out = '';
706
        while ($i < $len) {
707
            my $ch = substr($text, $i++, 1);
708
            return $out if $ch eq '"';
709
            if ($ch eq "\\") {
710
                die "Bad JSON escape\n" if $i >= $len;
711
                my $esc = substr($text, $i++, 1);
712
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
713
                    $out .= $esc;
714
                } elsif ($esc eq 'b') {
715
                    $out .= "\b";
716
                } elsif ($esc eq 'f') {
717
                    $out .= "\f";
718
                } elsif ($esc eq 'n') {
719
                    $out .= "\n";
720
                } elsif ($esc eq 'r') {
721
                    $out .= "\r";
722
                } elsif ($esc eq 't') {
723
                    $out .= "\t";
724
                } elsif ($esc eq 'u') {
725
                    my $hex = substr($text, $i, 4);
726
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
727
                    $out .= chr(hex($hex));
728
                    $i += 4;
729
                } else {
730
                    die "Bad JSON escape\n";
731
                }
732
            } else {
733
                $out .= $ch;
734
            }
735
        }
736
        die "Unterminated JSON string\n";
737
    };
738

            
739
    $parse_number = sub {
740
        my $start = $i;
741
        $i++ if substr($text, $i, 1) eq '-';
742
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
743
        if ($i < $len && substr($text, $i, 1) eq '.') {
744
            $i++;
745
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
746
        }
747
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
748
            $i++;
749
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
750
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
751
        }
752
        return 0 + substr($text, $start, $i - $start);
753
    };
754

            
755
    $parse_array = sub {
756
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
757
        $i++;
758
        my @out;
759
        $skip_ws->();
760
        if ($i < $len && substr($text, $i, 1) eq ']') {
761
            $i++;
762
            return \@out;
763
        }
764
        while (1) {
765
            push @out, $parse_value->();
766
            $skip_ws->();
767
            my $ch = substr($text, $i++, 1);
768
            last if $ch eq ']';
769
            die "Expected JSON array comma\n" unless $ch eq ',';
770
        }
771
        return \@out;
772
    };
773

            
774
    $parse_object = sub {
775
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
776
        $i++;
777
        my %out;
778
        $skip_ws->();
779
        if ($i < $len && substr($text, $i, 1) eq '}') {
780
            $i++;
781
            return \%out;
782
        }
783
        while (1) {
784
            $skip_ws->();
785
            my $key = $parse_string->();
786
            $skip_ws->();
787
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
788
            $out{$key} = $parse_value->();
789
            $skip_ws->();
790
            my $ch = substr($text, $i++, 1);
791
            last if $ch eq '}';
792
            die "Expected JSON object comma\n" unless $ch eq ',';
793
        }
794
        return \%out;
795
    };
796

            
797
    $parse_value = sub {
798
        $skip_ws->();
799
        die "Unexpected end of JSON\n" if $i >= $len;
800
        my $ch = substr($text, $i, 1);
801
        return $parse_string->() if $ch eq '"';
802
        return $parse_object->() if $ch eq '{';
803
        return $parse_array->() if $ch eq '[';
804
        if (substr($text, $i, 4) eq 'true') {
805
            $i += 4;
806
            return json_bool(1);
807
        }
808
        if (substr($text, $i, 5) eq 'false') {
809
            $i += 5;
810
            return json_bool(0);
811
        }
812
        if (substr($text, $i, 4) eq 'null') {
813
            $i += 4;
814
            return undef;
815
        }
816
        return $parse_number->() if $ch =~ /[-0-9]/;
817
        die "Unexpected JSON token\n";
818
    };
819

            
820
    my $value = $parse_value->();
821
    $skip_ws->();
822
    die "Trailing JSON content\n" if $i != $len;
823
    return $value;
824
}
825

            
826
sub parse_params {
827
    my ($text) = @_;
828
    my %out;
829
    for my $pair (split /&/, $text) {
830
        next unless length $pair;
831
        my ($k, $v) = split /=/, $pair, 2;
832
        $out{url_decode($k)} = url_decode($v || '');
833
    }
834
    return %out;
835
}
836

            
837
sub clean_id {
838
    my ($value) = @_;
839
    $value = lc clean_scalar($value);
840
    $value =~ s/[^a-z0-9_.-]+/-/g;
841
    $value =~ s/^-+|-+$//g;
842
    return $value;
843
}
844

            
845
sub clean_scalar {
846
    my ($value) = @_;
847
    $value = '' unless defined $value;
848
    $value =~ s/[\r\n\t]+/ /g;
849
    $value =~ s/^\s+|\s+$//g;
850
    return $value;
851
}
852

            
853
sub clean_list {
854
    my ($value) = @_;
855
    return () unless defined $value;
856
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
857
    my @clean;
858
    for my $item (@items) {
859
        $item = clean_scalar($item);
860
        push @clean, $item if length $item;
861
    }
862
    return @clean;
863
}
864

            
865
sub yq {
866
    my ($value) = @_;
867
    $value = '' unless defined $value;
868
    $value =~ s/\\/\\\\/g;
869
    $value =~ s/"/\\"/g;
870
    return qq("$value");
871
}
872

            
873
sub yaml_unquote {
874
    my ($value) = @_;
875
    $value = '' unless defined $value;
876
    $value =~ s/^\s+|\s+$//g;
877
    if ($value =~ /^"(.*)"$/) {
878
        $value = $1;
879
        $value =~ s/\\"/"/g;
880
        $value =~ s/\\\\/\\/g;
881
    }
882
    return $value;
883
}
884

            
885
sub verify_totp {
886
    my ($secret, $otp) = @_;
887
    return 0 unless $secret && $otp =~ /^\d{6}$/;
888
    my $key = eval { base32_decode($secret) };
889
    return 0 if $@ || !length $key;
890
    my $counter = int(time() / 30);
891
    for my $offset (-1, 0, 1) {
892
        return 1 if totp_code($key, $counter + $offset) eq $otp;
893
    }
894
    return 0;
895
}
896

            
897
sub totp_code {
898
    my ($key, $counter) = @_;
899
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
900
    my $hash = hmac_sha1($msg, $key);
901
    my $offset = ord(substr($hash, -1)) & 0x0f;
902
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
903
    return sprintf('%06d', $bin % 1_000_000);
904
}
905

            
906
sub base32_decode {
907
    my ($text) = @_;
908
    $text = uc($text || '');
909
    $text =~ s/[^A-Z2-7]//g;
910
    my %map;
911
    my @chars = ('A'..'Z', '2'..'7');
912
    @map{@chars} = (0..31);
913
    my ($bits, $value, $out) = (0, 0, '');
914
    for my $char (split //, $text) {
915
        die "Invalid base32\n" unless exists $map{$char};
916
        $value = ($value << 5) | $map{$char};
917
        $bits += 5;
918
        while ($bits >= 8) {
919
            $bits -= 8;
920
            $out .= chr(($value >> $bits) & 0xff);
921
        }
922
    }
923
    return $out;
924
}
925

            
926
sub create_session {
927
    my $nonce = random_hex(24);
928
    my $expires = int(time() + 8 * 3600);
929
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
930
    my $token = "$nonce:$expires:$sig";
931
    $sessions{$token} = $expires;
932
    return $token;
933
}
934

            
935
sub is_authenticated {
936
    my ($headers) = @_;
937
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
938
    return 0 unless $token;
939
    my ($nonce, $expires, $sig) = split /:/, $token;
940
    return 0 unless $nonce && $expires && $sig;
941
    return 0 if $expires < time();
942
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
943
    return exists $sessions{$token};
944
}
945

            
946
sub expire_session {
947
    my ($headers) = @_;
948
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
949
    delete $sessions{$token} if $token;
950
}
951

            
952
sub cookie_value {
953
    my ($cookie, $name) = @_;
954
    for my $part (split /;\s*/, $cookie) {
955
        my ($k, $v) = split /=/, $part, 2;
956
        return $v if defined $k && $k eq $name;
957
    }
958
    return '';
959
}
960

            
961
sub send_json {
962
    my ($client, $status, $payload, $extra_headers) = @_;
963
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
964
}
965

            
Xdev Host Manager authored 2 days ago
966
sub send_json_raw {
967
    my ($client, $status, $json_body, $extra_headers) = @_;
968
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
969
}
970

            
Xdev Host Manager authored 2 days ago
971
sub send_html {
972
    my ($client, $status, $html) = @_;
973
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
974
}
975

            
976
sub send_text {
977
    my ($client, $status, $text) = @_;
978
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
979
}
980

            
981
sub send_download {
982
    my ($client, $status, $content, $type, $filename) = @_;
983
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
984
}
985

            
986
sub send_file {
987
    my ($client, $path, $type, $filename) = @_;
988
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
989
    return send_download($client, 200, read_file($path), $type, $filename);
990
}
991

            
992
sub send_response {
993
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored 2 days ago
994
    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 2 days ago
995
    $body = '' unless defined $body;
996
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
997
    print $client "Content-Type: $type\r\n";
998
    print $client "Content-Length: " . length($body) . "\r\n";
999
    print $client "Cache-Control: no-store\r\n";
1000
    print $client "$_\r\n" for @{ $extra_headers || [] };
1001
    print $client "Connection: close\r\n\r\n";
1002
    print $client $body;
1003
}
1004

            
1005
sub read_file {
1006
    my ($path) = @_;
1007
    open my $fh, '<', $path or die "Cannot read $path: $!";
1008
    local $/;
1009
    return <$fh>;
1010
}
1011

            
1012
sub write_file {
1013
    my ($path, $content) = @_;
1014
    open my $fh, '>', $path or die "Cannot write $path: $!";
1015
    print {$fh} $content;
1016
    close $fh or die "Cannot close $path: $!";
1017
}
1018

            
1019
sub backup_file {
1020
    my ($path) = @_;
1021
    return unless -f $path;
1022
    my $backup_dir = "$project_dir/backups/host-manager";
1023
    make_path($backup_dir) unless -d $backup_dir;
1024
    my $name = $path;
1025
    $name =~ s{.*/}{};
1026
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1027
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1028
}
1029

            
1030
sub url_decode {
1031
    my ($value) = @_;
1032
    $value = '' unless defined $value;
1033
    $value =~ tr/+/ /;
1034
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
1035
    return $value;
1036
}
1037

            
1038
sub random_hex {
1039
    my ($bytes) = @_;
1040
    if (open my $fh, '<:raw', '/dev/urandom') {
1041
        read($fh, my $raw, $bytes);
1042
        close $fh;
1043
        return unpack('H*', $raw);
1044
    }
1045
    return sha256_hex(rand() . time() . $$);
1046
}
1047

            
1048
sub iso_now {
1049
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
1050
}
1051

            
1052
sub app_html {
1053
    return <<'HTML';
1054
<!doctype html>
1055
<html lang="ro">
1056
<head>
1057
  <meta charset="utf-8">
1058
  <meta name="viewport" content="width=device-width, initial-scale=1">
1059
  <title>Host Manager</title>
1060
  <style>
1061
    :root {
1062
      color-scheme: light;
1063
      --ink: #152033;
1064
      --muted: #647084;
1065
      --line: #d8dee8;
1066
      --soft: #f4f6f9;
1067
      --panel: #ffffff;
1068
      --accent: #1267d8;
1069
      --bad: #b42318;
1070
      --warn: #946200;
1071
      --ok: #137333;
1072
    }
1073
    * { box-sizing: border-box; }
1074
    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 2 days ago
1075

            
1076
    /* ── Login screen ── */
1077
    #login-screen {
1078
      display: flex;
1079
      align-items: center;
1080
      justify-content: center;
1081
      min-height: 100dvh;
1082
      padding: 24px;
1083
      background: #13182a;
1084
    }
1085
    .login-card {
1086
      background: #fff;
1087
      border-radius: 16px;
1088
      padding: 44px 36px 36px;
1089
      width: 100%;
1090
      max-width: 380px;
1091
      display: grid;
1092
      gap: 24px;
1093
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
1094
    }
1095
    .login-card .brand { text-align: center; display: grid; gap: 6px; }
1096
    .login-card .brand .icon {
1097
      margin: 0 auto 4px;
1098
      width: 52px; height: 52px; border-radius: 14px;
1099
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
1100
    }
1101
    .login-card .brand .icon svg { width: 26px; height: 26px; fill: var(--accent); }
1102
    .login-card .brand h1 { margin: 0; font-size: 22px; font-weight: 700; color: var(--ink); }
1103
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 13px; }
1104
    .login-card form { display: grid; gap: 16px; }
1105
    .login-card .field-label { font-size: 13px; font-weight: 600; color: var(--ink); }
1106
    /* 6 separate OTP digit boxes */
1107
    .otp-row { display: flex; gap: 8px; justify-content: center; }
1108
    .otp-row input {
1109
      width: 48px; height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
1110
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
1111
      background: #f8fafc; caret-color: transparent; outline: none;
1112
      transition: border-color .15s, background .15s;
1113
    }
1114
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
1115
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
1116
    .login-card button.primary {
1117
      width: 100%; border: none; background: var(--accent); color: #fff;
1118
      border-radius: 10px; padding: 13px; font: inherit; font-size: 15px;
1119
      font-weight: 600; cursor: pointer; min-height: 48px;
1120
      display: flex; align-items: center; justify-content: center; gap: 8px;
1121
    }
1122
    .login-card button.primary:hover { background: #0f52b8; }
1123
    .login-card button.primary:disabled { opacity: .55; cursor: not-allowed; }
1124
    #login-error {
1125
      color: var(--bad); font-size: 13px; text-align: center;
1126
      min-height: 18px; margin-top: -8px;
1127
    }
1128

            
1129
    /* ── App shell (hidden until authenticated) ── */
1130
    #app { display: none; }
1131
    header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
1132
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
1133
    .header-right { display: flex; align-items: center; gap: 10px; }
Xdev Host Manager authored 2 days ago
1134
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
1135
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
1136
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
1137
    .panel { overflow: hidden; }
1138
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
1139
    .panel-head h2 { margin: 0; font-size: 14px; }
1140
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
1141
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
1142
    button, input, select, textarea { font: inherit; }
1143
    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; }
1144
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
1145
    button.danger { color: var(--bad); }
Xdev Host Manager authored 2 days ago
1146
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored 2 days ago
1147
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
1148
    textarea { min-height: 74px; resize: vertical; }
1149
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
1150
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
1151
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
1152
    tr:hover td { background: #f8fafc; }
1153
    .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; }
1154
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
1155
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
1156
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
1157
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
1158
    .span2 { grid-column: 1 / -1; }
1159
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
1160
    .muted { color: var(--muted); }
1161
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
1162
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
1163
    @media (max-width: 760px) {
1164
      .grid { grid-template-columns: 1fr; }
1165
      table { min-width: 760px; }
1166
      .table-wrap { overflow-x: auto; }
1167
    }
1168
  </style>
1169
</head>
1170
<body>
1171

            
Xdev Host Manager authored 2 days ago
1172
  <!-- ── Login screen ── -->
1173
  <div id="login-screen">
1174
    <div class="login-card">
1175
      <div class="brand">
1176
        <div class="icon">
1177
          <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
1178
            <path d="M20 3H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zm-1 5H5V5h14v3zm1 5H4a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1zm-1 5H5v-3h14v3z"/>
1179
            <circle cx="17" cy="7" r="1"/><circle cx="19.5" cy="7" r="1"/>
1180
            <circle cx="17" cy="15.5" r="1"/><circle cx="19.5" cy="15.5" r="1"/>
1181
          </svg>
1182
        </div>
1183
        <h1>Host Manager</h1>
1184
        <p>madagascar.xdev.ro</p>
Xdev Host Manager authored 2 days ago
1185
      </div>
Xdev Host Manager authored 2 days ago
1186
      <form id="login-form">
1187
        <div class="field-label">Cod Authenticator</div>
1188
        <div class="otp-row">
1189
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit" autocomplete="one-time-code">
1190
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
1191
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
1192
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
1193
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
1194
          <input type="text" inputmode="numeric" maxlength="1" pattern="[0-9]" class="otp-digit">
1195
        </div>
1196
        <button class="primary" type="submit" id="login-btn">Autentifică-te</button>
1197
      </form>
1198
      <div id="login-error"></div>
1199
    </div>
1200
  </div>
1201

            
1202
  <!-- ── App (shown after login) ── -->
1203
  <div id="app">
1204
    <header>
1205
      <h1>Host Manager</h1>
1206
      <div class="header-right">
1207
        <span class="muted" id="app-updated"></span>
1208
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored 2 days ago
1209
      </div>
Xdev Host Manager authored 2 days ago
1210
    </header>
1211
    <main>
1212
      <section class="toolbar">
1213
        <button id="refresh">Refresh</button>
1214
        <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
1215
        <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
1216
        <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
1217
        <button id="write-tsv">Write local-hosts.tsv</button>
1218
        <span id="message" class="muted"></span>
1219
      </section>
1220

            
1221
      <section class="panel">
1222
        <div class="panel-head">
1223
          <h2>Overview</h2>
1224
          <div class="stats" id="stats"></div>
1225
        </div>
1226
        <div class="problems" id="problems"></div>
1227
      </section>
Xdev Host Manager authored 2 days ago
1228

            
Xdev Host Manager authored 2 days ago
1229
      <section class="panel">
1230
        <div class="panel-head">
1231
          <h2>Certificate Authority</h2>
1232
          <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
1233
        </div>
1234
        <div class="problems" id="ca-status"></div>
1235
      </section>
1236

            
Xdev Host Manager authored 2 days ago
1237
      <section class="panel">
1238
        <div class="panel-head">
1239
          <h2>Work Orders</h2>
1240
          <div class="stats" id="wo-stats"></div>
1241
        </div>
1242
        <div class="problems" id="work-orders"></div>
1243
      </section>
1244

            
Xdev Host Manager authored 2 days ago
1245
      <section class="panel">
1246
        <div class="panel-head">
1247
          <h2>Hosts</h2>
1248
          <input id="filter" placeholder="filter" style="max-width: 240px">
Xdev Host Manager authored 2 days ago
1249
        </div>
Xdev Host Manager authored 2 days ago
1250
        <div class="table-wrap">
1251
          <table>
1252
            <thead>
1253
              <tr>
1254
                <th style="width: 120px">ID</th>
1255
                <th style="width: 130px">hosts_ip</th>
1256
                <th style="width: 130px">dns_ip</th>
1257
                <th>Names</th>
1258
                <th style="width: 150px">Roles</th>
1259
                <th style="width: 110px">Monitoring</th>
1260
                <th style="width: 90px">Status</th>
1261
              </tr>
1262
            </thead>
1263
            <tbody id="hosts"></tbody>
1264
          </table>
1265
        </div>
1266
      </section>
1267

            
1268
      <section class="panel">
1269
        <div class="panel-head">
1270
          <h2>Edit host</h2>
1271
        </div>
1272
        <form id="host-form" class="grid">
1273
          <label>ID<input name="id" required></label>
1274
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
1275
          <label>hosts_ip<input name="hosts_ip" required></label>
1276
          <label>dns_ip<input name="dns_ip" required></label>
1277
          <label class="span2">Names<textarea name="names" required></textarea></label>
1278
          <label>Roles<input name="roles"></label>
1279
          <label>Sources<input name="sources"></label>
1280
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
1281
          <label>Notes<input name="notes"></label>
1282
          <div class="span2">
1283
            <button class="primary" type="submit">Save host</button>
1284
            <button class="danger" type="button" id="delete-host">Delete host</button>
1285
          </div>
1286
        </form>
1287
      </section>
1288
    </main>
1289
  </div>
1290

            
Xdev Host Manager authored 2 days ago
1291
  <script>
Xdev Host Manager authored 2 days ago
1292
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false };
Xdev Host Manager authored 2 days ago
1293

            
1294
    const $ = (id) => document.getElementById(id);
1295
    const msg = (text) => { $('message').textContent = text || ''; };
1296

            
1297
    async function api(path, options = {}) {
1298
      const res = await fetch(path, options);
1299
      const body = await res.json();
1300
      if (!res.ok) throw new Error(body.error || res.statusText);
1301
      return body;
1302
    }
1303

            
Xdev Host Manager authored 2 days ago
1304
    function showLogin(errorText) {
1305
      $('app').style.display = 'none';
1306
      $('login-screen').style.display = 'flex';
1307
      $('login-error').textContent = errorText || '';
1308
      document.querySelectorAll('.otp-digit').forEach(i => { i.value = ''; i.classList.remove('filled'); });
1309
      const first = document.querySelector('.otp-digit');
1310
      if (first) first.focus();
1311
    }
1312

            
1313
    function showApp() {
1314
      $('login-screen').style.display = 'none';
1315
      $('app').style.display = 'block';
1316
    }
1317

            
Xdev Host Manager authored 2 days ago
1318
    async function refresh() {
1319
      const session = await api('/api/session');
1320
      state.authenticated = session.authenticated;
Xdev Host Manager authored 2 days ago
1321
      if (!state.authenticated) { showLogin(); return; }
1322
      showApp();
Xdev Host Manager authored 2 days ago
1323
      const data = await api('/api/hosts');
1324
      state.hosts = data.hosts || [];
1325
      state.problems = data.problems || [];
1326
      render(data);
Xdev Host Manager authored 2 days ago
1327
      await renderCa();
Xdev Host Manager authored 2 days ago
1328
      await renderWorkOrders();
Xdev Host Manager authored 2 days ago
1329
    }
1330

            
1331
    function render(data) {
Xdev Host Manager authored 2 days ago
1332
      $('app-updated').textContent = data.updated_at ? 'updated ' + data.updated_at : '';
1333

            
Xdev Host Manager authored 2 days ago
1334
      $('stats').innerHTML = [
1335
        ['hosts', data.counts.hosts],
1336
        ['problems', data.counts.problems],
1337
      ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1338

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

            
1343
      renderHosts();
1344
    }
1345

            
Xdev Host Manager authored 2 days ago
1346
    async function renderCa() {
1347
      try {
1348
        const status = await api('/api/ca/status');
1349
        if (!status.initialized) {
1350
          $('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
1351
          return;
1352
        }
1353
        const certs = await api('/api/ca/certificates');
1354
        $('ca-status').innerHTML = `
1355
          <div class="muted" style="display:grid;gap:6px">
1356
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
1357
            <div>SHA256 ${escapeHtml(status.fingerprint_sha256 || '')}</div>
1358
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
1359
            <div>${certs.length} issued certificate(s)</div>
1360
          </div>`;
1361
      } catch (e) {
1362
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
1363
      }
1364
    }
1365

            
Xdev Host Manager authored 2 days ago
1366
    async function renderWorkOrders() {
1367
      try {
1368
        const data = await api('/api/work-orders');
1369
        state.workOrders = data.work_orders || [];
1370
        $('wo-stats').innerHTML = [
1371
          ['pending', data.counts.pending],
1372
          ['total', data.counts.work_orders],
1373
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
1374

            
1375
        if (!state.workOrders.length) {
1376
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
1377
          return;
1378
        }
1379

            
1380
        $('work-orders').innerHTML = state.workOrders.map(wo => {
1381
          const actions = (wo.actions || []).map(a => {
1382
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
1383
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
1384
          }).join('');
1385
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
1386
          const button = (wo.status || 'pending') === 'pending'
1387
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}">Confirm</button>`
1388
            : '';
1389
          return `<div class="problem" style="display:grid;gap:8px">
1390
            <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
1391
              <div><strong>${escapeHtml(wo.id || '')}</strong> <span class="pill ${statusClass}">${escapeHtml(wo.status || 'pending')}</span></div>
1392
              ${button}
1393
            </div>
1394
            <div>${escapeHtml(wo.title || '')}</div>
1395
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
1396
            <div style="display:grid;gap:4px">${actions}</div>
1397
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
1398
          </div>`;
1399
        }).join('');
1400
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
1401
      } catch (e) {
1402
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
1403
      }
1404
    }
1405

            
1406
    async function confirmWorkOrder(id) {
1407
      const typed = prompt(`Type ${id} to confirm this work order`);
1408
      if (typed !== id) return;
1409
      try {
1410
        await api('/api/work-orders/confirm', {
1411
          method: 'POST',
1412
          headers: { 'Content-Type': 'application/json' },
1413
          body: JSON.stringify({ id, confirm: typed })
1414
        });
1415
        msg('work order confirmed; local-hosts.tsv written');
1416
        await refresh();
1417
      } catch (e) { msg(e.message); }
1418
    }
1419

            
Xdev Host Manager authored 2 days ago
1420
    function renderHosts() {
1421
      const filter = $('filter').value.toLowerCase();
1422
      $('hosts').innerHTML = state.hosts
1423
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
1424
        .map(h => {
1425
          const problems = state.problems.filter(p => p.host_id === h.id);
1426
          const cls = problems.length ? 'warn' : 'ok';
1427
          return `<tr data-id="${escapeHtml(h.id)}">
1428
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
1429
            <td>${escapeHtml(h.hosts_ip || '')}</td>
1430
            <td>${escapeHtml(h.dns_ip || '')}</td>
1431
            <td>${(h.names || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
1432
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
1433
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
1434
            <td>${escapeHtml(h.status || '')}</td>
1435
          </tr>`;
1436
        }).join('');
1437
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => editHost(button.dataset.edit)));
1438
    }
1439

            
1440
    function editHost(id) {
1441
      const host = state.hosts.find(h => h.id === id);
1442
      if (!host) return;
1443
      const form = $('host-form');
1444
      for (const key of ['id', 'status', 'hosts_ip', 'dns_ip', 'monitoring', 'notes']) form.elements[key].value = host[key] || '';
1445
      form.elements.names.value = (host.names || []).join('\n');
1446
      form.elements.roles.value = (host.roles || []).join(' ');
1447
      form.elements.sources.value = (host.sources || []).join(' ');
Xdev Host Manager authored 2 days ago
1448
      form.scrollIntoView({ behavior: 'smooth', block: 'start' });
Xdev Host Manager authored 2 days ago
1449
    }
1450

            
1451
    function formObject(form) {
1452
      return Object.fromEntries(new FormData(form).entries());
1453
    }
1454

            
1455
    function escapeHtml(value) {
1456
      return value.replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[ch]));
1457
    }
1458

            
Xdev Host Manager authored 2 days ago
1459
    // OTP digit boxes — auto-advance, backspace, paste
1460
    const otpDigits = Array.from(document.querySelectorAll('.otp-digit'));
1461
    otpDigits[0].focus();
1462

            
1463
    otpDigits.forEach((input, idx) => {
1464
      input.addEventListener('keydown', (e) => {
1465
        if (e.key === 'Backspace') {
1466
          if (input.value) { input.value = ''; input.classList.remove('filled'); }
1467
          else if (idx > 0) { otpDigits[idx - 1].value = ''; otpDigits[idx - 1].classList.remove('filled'); otpDigits[idx - 1].focus(); }
1468
          e.preventDefault();
1469
        }
1470
      });
1471
      input.addEventListener('input', (e) => {
1472
        const val = input.value.replace(/\D/g, '').slice(-1);
1473
        input.value = val;
1474
        input.classList.toggle('filled', !!val);
1475
        if (val && idx < otpDigits.length - 1) otpDigits[idx + 1].focus();
1476
        if (val && idx === otpDigits.length - 1) $('login-form').requestSubmit();
1477
      });
1478
      input.addEventListener('paste', (e) => {
1479
        const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '');
1480
        e.preventDefault();
1481
        text.split('').slice(0, otpDigits.length).forEach((ch, i) => {
1482
          otpDigits[i].value = ch;
1483
          otpDigits[i].classList.add('filled');
1484
        });
1485
        const next = Math.min(text.length, otpDigits.length - 1);
1486
        otpDigits[next].focus();
1487
        if (text.length >= otpDigits.length) $('login-form').requestSubmit();
1488
      });
1489
    });
1490

            
1491
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
1492
    function clearOtp() { otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); }); otpDigits[0].focus(); }
Xdev Host Manager authored 2 days ago
1493

            
1494
    $('login-form').addEventListener('submit', async (event) => {
1495
      event.preventDefault();
Xdev Host Manager authored 2 days ago
1496
      const btn = $('login-btn');
1497
      btn.disabled = true;
1498
      $('login-error').textContent = '';
Xdev Host Manager authored 2 days ago
1499
      try {
Xdev Host Manager authored 2 days ago
1500
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored 2 days ago
1501
        await refresh();
Xdev Host Manager authored 2 days ago
1502
      } catch (e) {
1503
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
1504
      } finally {
1505
        btn.disabled = false;
1506
      }
Xdev Host Manager authored 2 days ago
1507
    });
1508

            
1509
    $('logout').addEventListener('click', async () => {
1510
      await api('/api/logout', { method: 'POST' }).catch(() => {});
Xdev Host Manager authored 2 days ago
1511
      clearOtp();
1512
      showLogin();
Xdev Host Manager authored 2 days ago
1513
    });
1514

            
Xdev Host Manager authored 2 days ago
1515
    $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
1516
    $('filter').addEventListener('input', renderHosts);
1517

            
Xdev Host Manager authored 2 days ago
1518
    $('host-form').addEventListener('submit', async (event) => {
1519
      event.preventDefault();
1520
      try {
1521
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
1522
        msg('host saved');
1523
        await refresh();
1524
      } catch (e) { msg(e.message); }
1525
    });
1526

            
1527
    $('delete-host').addEventListener('click', async () => {
1528
      const id = $('host-form').elements.id.value;
1529
      if (!id || !confirm(`Delete ${id}?`)) return;
1530
      try {
1531
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
1532
        $('host-form').reset();
1533
        msg('host deleted');
1534
        await refresh();
1535
      } catch (e) { msg(e.message); }
1536
    });
1537

            
1538
    $('write-tsv').addEventListener('click', async () => {
1539
      if (!confirm('Write config/local-hosts.tsv from hosts.yaml?')) return;
1540
      try {
1541
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
1542
        msg('local-hosts.tsv written');
1543
      } catch (e) { msg(e.message); }
1544
    });
1545

            
Xdev Host Manager authored 2 days ago
1546
    refresh().catch(() => showLogin());
Xdev Host Manager authored 2 days ago
1547
  </script>
1548
</body>
1549
</html>
1550
HTML
1551
}