LocalAuthority / scripts / host_manager.pl
Newer Older
4100 lines | 159.495kb
Xdev Host Manager authored a week ago
1
#!/usr/bin/env perl
2
#
3
# host_manager.pl - Minimal host registry web app with no CPAN dependencies.
4
#
5

            
6
use strict;
7
use warnings;
8

            
9
use Cwd qw(abs_path);
Bogdan Timofte authored 4 days ago
10
use DBI;
Xdev Host Manager authored a week ago
11
use Digest::SHA qw(hmac_sha1 hmac_sha256_hex sha256_hex);
12
use File::Basename qw(dirname);
13
use File::Path qw(make_path);
14
use IO::Socket::INET;
15
use POSIX qw(strftime);
16
use Time::HiRes qw(time);
17

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

            
21
my %opt = (
22
    bind => $ENV{HOST_MANAGER_BIND} || '127.0.0.1',
23
    port => $ENV{HOST_MANAGER_PORT} || 8088,
Bogdan Timofte authored 4 days ago
24
    db => $ENV{HOST_MANAGER_DB} || "$project_dir/var/host-manager.sqlite",
Xdev Host Manager authored a week ago
25
    data => $ENV{HOST_MANAGER_DATA} || "$project_dir/config/hosts.yaml",
26
    local_hosts_tsv => $ENV{HOST_MANAGER_LOCAL_HOSTS_TSV} || "$project_dir/config/local-hosts.tsv",
Xdev Host Manager authored a week ago
27
    work_orders => $ENV{HOST_MANAGER_WORK_ORDERS} || "$project_dir/config/work-orders.yaml",
Xdev Host Manager authored a week ago
28
);
29

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

            
52
my $session_secret = $ENV{HOST_MANAGER_SESSION_SECRET} || random_hex(32);
53
my %sessions;
54

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

            
63
print "host-manager listening on http://$opt{bind}:$opt{port}\n";
Bogdan Timofte authored 4 days ago
64
print "database: $opt{db}\n";
65
print "seed/export hosts file: $opt{data}\n";
Xdev Host Manager authored a week ago
66
print "OTP login: " . ($ENV{HOST_MANAGER_TOTP_SECRET} ? "enabled\n" : "disabled; set HOST_MANAGER_TOTP_SECRET\n");
67

            
68
while (my $client = $server->accept) {
69
    eval {
70
        $client->autoflush(1);
71
        handle_client($client);
72
    };
73
    if ($@) {
74
        eval { send_json($client, 500, { error => 'internal_error', detail => "$@" }); };
75
    }
76
    close $client;
77
}
78

            
79
sub usage {
80
    print <<"EOF";
81
Usage: perl scripts/host_manager.pl [--bind 127.0.0.1] [--port 8088]
82

            
83
Environment:
84
  HOST_MANAGER_TOTP_SECRET      Base32 TOTP secret required for write access.
85
  HOST_MANAGER_SESSION_SECRET   Optional session signing secret.
Bogdan Timofte authored 4 days ago
86
  HOST_MANAGER_DB               Defaults to var/host-manager.sqlite.
Xdev Host Manager authored a week ago
87
  HOST_MANAGER_DATA             Defaults to config/hosts.yaml.
88
  HOST_MANAGER_LOCAL_HOSTS_TSV  Defaults to config/local-hosts.tsv.
Xdev Host Manager authored a week ago
89
  HOST_MANAGER_WORK_ORDERS      Defaults to config/work-orders.yaml.
Xdev Host Manager authored a week ago
90

            
Bogdan Timofte authored 4 days ago
91
SQLite is the runtime source of truth. YAML files seed a new database and remain
92
download/export compatibility artifacts. The nginx vhost keeps registry, CA,
93
work order and download endpoints behind OTP.
Xdev Host Manager authored a week ago
94
EOF
95
}
96

            
97
sub handle_client {
98
    my ($client) = @_;
99
    my $request_line = <$client>;
100
    return unless defined $request_line;
101
    $request_line =~ s/\r?\n$//;
102
    my ($method, $target) = $request_line =~ m{^([A-Z]+)\s+(\S+)\s+HTTP/};
103
    return send_text($client, 400, 'bad request') unless $method && $target;
104

            
105
    my %headers;
106
    while (my $line = <$client>) {
107
        $line =~ s/\r?\n$//;
108
        last if $line eq '';
109
        my ($k, $v) = split /:\s*/, $line, 2;
110
        $headers{lc $k} = $v if defined $k && defined $v;
111
    }
112

            
113
    my $body = '';
114
    if (($headers{'content-length'} || 0) > 0) {
115
        read($client, $body, int($headers{'content-length'}));
116
    }
117

            
118
    my ($path, $query) = split /\?/, $target, 2;
119
    my %query = parse_params($query || '');
120

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

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

            
Xdev Host Manager authored a week ago
147
    if ($method eq 'GET' && $path eq '/api/hosts') {
148
        my $registry = load_registry();
149
        return send_json($client, 200, registry_payload($registry));
150
    }
Xdev Host Manager authored a week ago
151
    if ($method eq 'GET' && $path eq '/api/work-orders') {
152
        return send_json($client, 200, work_orders_payload(load_work_orders()));
153
    }
Bogdan Timofte authored 4 days ago
154
    if ($method eq 'GET' && $path eq '/api/debug/database/tables') {
155
        return send_json($client, 200, debug_database_tables_payload());
156
    }
157
    if ($method eq 'GET' && $path eq '/api/debug/database/table') {
158
        return send_json($client, 200, debug_database_table_payload($query{name} || $query{table} || '', $query{limit} || 100));
159
    }
Bogdan Timofte authored 4 days ago
160
    if ($method eq 'GET' && $path eq '/download/debug/database/table.json') {
161
        my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
162
        return send_json($client, 400, { error => $export->{error} }) if $export->{error};
163
        return send_download($client, 200, json_encode($export), 'application/json; charset=utf-8', debug_table_export_filename($export->{table}, 'json'));
164
    }
165
    if ($method eq 'GET' && $path eq '/download/debug/database/table.csv') {
166
        my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
167
        return send_json($client, 400, { error => $export->{error} }) if $export->{error};
168
        return send_download($client, 200, render_debug_table_csv($export), 'text/csv; charset=utf-8', debug_table_export_filename($export->{table}, 'csv'));
169
    }
Xdev Host Manager authored a week ago
170
    if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
Bogdan Timofte authored 4 days ago
171
        my $registry = load_registry();
172
        return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
Xdev Host Manager authored a week ago
173
    }
174
    if ($method eq 'GET' && $path eq '/download/local-hosts.tsv') {
175
        my $registry = load_registry();
176
        return send_download($client, 200, render_local_hosts_tsv($registry), 'text/tab-separated-values; charset=utf-8', 'local-hosts.tsv');
177
    }
178
    if ($method eq 'GET' && $path eq '/download/monitoring.json') {
179
        my $registry = load_registry();
180
        return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json');
181
    }
Xdev Host Manager authored a week ago
182
    if ($method eq 'GET' && $path eq '/api/ca/status') {
183
        return send_json_raw($client, 200, ca_manager_json('status-json'));
184
    }
185
    if ($method eq 'GET' && $path eq '/api/ca/certificates') {
186
        return send_json_raw($client, 200, ca_manager_json('list-json'));
187
    }
188
    if ($method eq 'GET' && $path eq '/download/ca.crt') {
189
        return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt');
190
    }
Bogdan Timofte authored 5 days ago
191
    if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
192
        my $name = $1;
193
        return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt");
194
    }
Xdev Host Manager authored a week ago
195

            
196
    if ($method eq 'POST' && $path =~ m{^/api/}) {
197
        if ($path eq '/api/hosts/upsert') {
198
            my $payload = request_payload(\%headers, $body);
199
            return upsert_host($client, $payload);
200
        }
201
        if ($path eq '/api/hosts/delete') {
202
            my $payload = request_payload(\%headers, $body);
203
            return delete_host($client, $payload->{id} || '');
204
        }
Bogdan Timofte authored 4 days ago
205
        if ($path eq '/api/vhosts/reassign') {
206
            my $payload = request_payload(\%headers, $body);
207
            return reassign_vhost($client, $payload);
208
        }
Bogdan Timofte authored 4 days ago
209
        if ($path eq '/api/vhosts/upsert') {
210
            my $payload = request_payload(\%headers, $body);
211
            return upsert_vhost($client, $payload);
212
        }
213
        if ($path eq '/api/vhosts/delete') {
214
            my $payload = request_payload(\%headers, $body);
215
            return delete_vhost($client, $payload);
216
        }
Xdev Host Manager authored a week ago
217
        if ($path eq '/api/work-orders/confirm') {
218
            my $payload = request_payload(\%headers, $body);
219
            return confirm_work_order($client, $payload);
220
        }
Xdev Host Manager authored a week ago
221
        if ($path eq '/api/work-orders/checklist') {
222
            my $payload = request_payload(\%headers, $body);
223
            return update_work_order_checklist($client, $payload);
224
        }
Xdev Host Manager authored a week ago
225
        if ($path eq '/api/render/local-hosts-tsv') {
226
            my $registry = load_registry();
227
            my $content = render_local_hosts_tsv($registry);
228
            backup_file($opt{local_hosts_tsv});
229
            write_file($opt{local_hosts_tsv}, $content);
230
            return send_json($client, 200, { ok => json_bool(1), file => $opt{local_hosts_tsv} });
231
        }
232
    }
233

            
234
    return send_json($client, 404, { error => 'not_found' });
235
}
236

            
Bogdan Timofte authored 5 days ago
237
sub app_page_path {
238
    my ($path) = @_;
Bogdan Timofte authored 4 days ago
239
    return $path =~ m{\A/(?:|overview|hosts|vhosts|dns|work-orders|ca|debug)\z};
Bogdan Timofte authored 5 days ago
240
}
241

            
Xdev Host Manager authored a week ago
242
sub load_registry {
Bogdan Timofte authored 4 days ago
243
    my $registry = load_registry_from_db();
Bogdan Timofte authored 4 days ago
244
    normalize_registry_policy($registry);
245
    return $registry;
Xdev Host Manager authored a week ago
246
}
247

            
248
sub save_registry {
249
    my ($registry) = @_;
250
    $registry->{updated_at} = iso_now();
Bogdan Timofte authored 4 days ago
251
    normalize_registry_policy($registry);
Bogdan Timofte authored 4 days ago
252
    save_registry_to_db($registry);
Xdev Host Manager authored a week ago
253
}
254

            
Xdev Host Manager authored a week ago
255
sub load_work_orders {
Bogdan Timofte authored 4 days ago
256
    return load_work_orders_from_db();
Xdev Host Manager authored a week ago
257
}
258

            
259
sub save_work_orders {
260
    my ($orders) = @_;
Bogdan Timofte authored 4 days ago
261
    save_work_orders_to_db($orders);
Xdev Host Manager authored a week ago
262
}
263

            
264
sub work_orders_payload {
265
    my ($orders) = @_;
266
    my $pending = 0;
267
    for my $wo (@{ $orders->{work_orders} || [] }) {
268
        $pending++ if ($wo->{status} || 'pending') eq 'pending';
269
    }
270
    return {
271
        version => $orders->{version},
272
        work_orders => $orders->{work_orders} || [],
273
        counts => {
274
            work_orders => scalar @{ $orders->{work_orders} || [] },
275
            pending => $pending,
276
        },
277
    };
278
}
279

            
280
sub confirm_work_order {
281
    my ($client, $payload) = @_;
282
    my $id = clean_scalar($payload->{id} || '');
283
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
284
    return send_json($client, 400, { error => 'confirmation_required' }) unless clean_scalar($payload->{confirm} || '') eq $id;
285

            
286
    my $orders = load_work_orders();
287
    my $work_order;
288
    for my $wo (@{ $orders->{work_orders} || [] }) {
289
        if (($wo->{id} || '') eq $id) {
290
            $work_order = $wo;
291
            last;
292
        }
293
    }
294
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
295
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
Xdev Host Manager authored a week ago
296
    my $incomplete = incomplete_work_order_items($work_order);
297
    return send_json($client, 409, {
298
        error => 'work_order_incomplete',
299
        incomplete => $incomplete,
300
    }) if @$incomplete;
Xdev Host Manager authored a week ago
301

            
302
    my $registry = load_registry();
303
    my $results = apply_work_order($registry, $work_order);
304
    $work_order->{status} = 'confirmed';
305
    $work_order->{confirmed_at} = iso_now();
306
    $work_order->{result} = scalar(@$results) . ' action(s) applied';
307

            
308
    save_registry($registry);
309
    save_work_orders($orders);
310
    backup_file($opt{local_hosts_tsv});
311
    write_file($opt{local_hosts_tsv}, render_local_hosts_tsv($registry));
312

            
313
    return send_json($client, 200, {
314
        ok => json_bool(1),
315
        work_order => $work_order,
316
        results => $results,
317
        local_hosts_tsv => $opt{local_hosts_tsv},
318
    });
319
}
320

            
Xdev Host Manager authored a week ago
321
sub update_work_order_checklist {
322
    my ($client, $payload) = @_;
323
    my $id = clean_scalar($payload->{id} || '');
324
    my $item_id = clean_scalar($payload->{item_id} || '');
325
    my $status = clean_scalar($payload->{status} || '');
326
    my $notes = clean_scalar($payload->{notes} || '');
327
    return send_json($client, 400, { error => 'invalid_work_order_id' }) unless $id =~ /\AWO-[A-Za-z0-9_.-]+\z/;
328
    return send_json($client, 400, { error => 'invalid_checklist_item' }) unless $item_id =~ /\A[A-Za-z0-9_.-]+\z/;
329
    return send_json($client, 400, { error => 'invalid_checklist_status' }) unless $status =~ /\A(?:pending|done|blocked)\z/;
330

            
331
    my $orders = load_work_orders();
332
    my $work_order;
333
    for my $wo (@{ $orders->{work_orders} || [] }) {
334
        if (($wo->{id} || '') eq $id) {
335
            $work_order = $wo;
336
            last;
337
        }
338
    }
339
    return send_json($client, 404, { error => 'work_order_not_found' }) unless $work_order;
340
    return send_json($client, 409, { error => 'work_order_not_pending' }) unless ($work_order->{status} || 'pending') eq 'pending';
341

            
342
    my $item;
343
    for my $candidate (@{ $work_order->{checklist} || [] }) {
344
        if (($candidate->{id} || '') eq $item_id) {
345
            $item = $candidate;
346
            last;
347
        }
348
    }
349
    return send_json($client, 404, { error => 'checklist_item_not_found' }) unless $item;
350

            
351
    $item->{status} = $status;
352
    $item->{updated_at} = iso_now();
353
    $item->{notes} = $notes if length $notes;
354
    save_work_orders($orders);
355
    return send_json($client, 200, { ok => json_bool(1), work_order => $work_order });
356
}
357

            
358
sub incomplete_work_order_items {
359
    my ($work_order) = @_;
360
    my @incomplete;
361
    for my $item (@{ $work_order->{checklist} || [] }) {
362
        push @incomplete, $item unless ($item->{status} || 'pending') eq 'done';
363
    }
364
    return \@incomplete;
365
}
366

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

            
Xdev Host Manager authored a week ago
398
sub registry_payload {
399
    my ($registry) = @_;
400
    my $problems = analyze_hosts($registry->{hosts});
Xdev Host Manager authored a week ago
401
    my @hosts = map { host_payload($_) } @{ $registry->{hosts} };
Bogdan Timofte authored 4 days ago
402
    my $vhost_count = sum(map { scalar declared_vhost_names($_) } @{ $registry->{hosts} });
Xdev Host Manager authored a week ago
403
    return {
404
        version => $registry->{version},
405
        updated_at => $registry->{updated_at},
406
        policy => $registry->{policy},
Xdev Host Manager authored a week ago
407
        hosts => \@hosts,
Xdev Host Manager authored a week ago
408
        problems => $problems,
409
        counts => {
410
            hosts => scalar @{ $registry->{hosts} },
Bogdan Timofte authored 4 days ago
411
            vhosts => $vhost_count,
Xdev Host Manager authored a week ago
412
            problems => scalar @$problems,
413
        },
414
    };
415
}
416

            
417
sub upsert_host {
418
    my ($client, $payload) = @_;
419
    my $id = clean_id($payload->{id} || '');
420
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
421

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

            
Bogdan Timofte authored 4 days ago
425
    my $fqdn = canonical_host_fqdn($payload);
426
    return send_json($client, 400, { error => 'missing_fqdn' }) unless $fqdn;
427
    my @aliases = clean_alias_names($payload);
Xdev Host Manager authored a week ago
428

            
429
    my $registry = load_registry();
Bogdan Timofte authored 4 days ago
430
    my ($existing_host) = grep { ($_->{id} || '') eq $id } @{ $registry->{hosts} || [] };
431
    my @vhosts = defined $payload->{vhosts}
432
        ? clean_vhost_names($payload)
433
        : ($existing_host ? declared_vhost_names($existing_host) : ());
Xdev Host Manager authored a week ago
434
    my %host = (
435
        id => $id,
Bogdan Timofte authored 4 days ago
436
        fqdn => $fqdn,
Xdev Host Manager authored a week ago
437
        status => clean_scalar($payload->{status} || 'active'),
Bogdan Timofte authored 4 days ago
438
        ip => $ip,
439
        aliases => \@aliases,
440
        vhosts => \@vhosts,
Xdev Host Manager authored a week ago
441
        roles => [ clean_list($payload->{roles}) ],
442
        sources => [ clean_list($payload->{sources}) ],
443
        monitoring => clean_scalar($payload->{monitoring} || 'pending'),
444
        notes => clean_scalar($payload->{notes} || ''),
445
    );
446

            
Bogdan Timofte authored 4 days ago
447
    my $response = eval {
448
        my $replaced = 0;
449
        for my $i (0 .. $#{ $registry->{hosts} }) {
450
            if ($registry->{hosts}->[$i]{id} eq $id) {
451
                $registry->{hosts}->[$i] = \%host;
452
                $replaced = 1;
453
                last;
454
            }
Xdev Host Manager authored a week ago
455
        }
Bogdan Timofte authored 4 days ago
456
        push @{ $registry->{hosts} }, \%host unless $replaced;
457
        save_registry($registry);
458
        1;
459
    };
460
    if (!$response) {
461
        my $err = $@ || 'upsert_failed';
462
        return send_json($client, 409, { error => 'alias_conflict', detail => clean_scalar($err) })
463
            if $err =~ /alias_conflict:/;
464
        die $err;
Xdev Host Manager authored a week ago
465
    }
466
    return send_json($client, 200, { ok => json_bool(1), host => \%host });
467
}
468

            
469
sub delete_host {
470
    my ($client, $id) = @_;
471
    $id = clean_id($id);
472
    return send_json($client, 400, { error => 'invalid_id' }) unless $id;
473

            
474
    my $registry = load_registry();
475
    my @kept = grep { $_->{id} ne $id } @{ $registry->{hosts} };
476
    return send_json($client, 404, { error => 'not_found' }) if @kept == @{ $registry->{hosts} };
477
    $registry->{hosts} = \@kept;
478
    save_registry($registry);
479
    return send_json($client, 200, { ok => json_bool(1) });
480
}
481

            
Bogdan Timofte authored 4 days ago
482
sub reassign_vhost {
483
    my ($client, $payload) = @_;
484
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
485
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
486
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
487
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
488

            
489
    my $dbh = dbh();
490
    my ($current_fqdn) = $dbh->selectrow_array(
491
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
492
        undef,
493
        $vhost,
494
    );
495
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
496
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
497
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $current_fqdn }) if $current_fqdn eq $target_fqdn;
498

            
499
    my $result = eval {
500
        with_transaction($dbh, sub {
501
            my $now = iso_now();
502
            $dbh->do(
503
                "UPDATE vhosts SET host_fqdn = ?, updated_at = ?, status = 'active' WHERE vhost_fqdn = ?",
504
                undef,
505
                $target_fqdn, $now, $vhost,
506
            );
507

            
508
            my $registry = load_registry_from_db();
509
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
510
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
511

            
512
            upsert_host_to_db($dbh, $target_host) if $target_host;
513
            upsert_host_to_db($dbh, $current_host) if $current_host;
Bogdan Timofte authored 4 days ago
514
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
Bogdan Timofte authored 4 days ago
515
        });
516
        1;
517
    };
518
    if (!$result) {
519
        my $err = $@ || 'vhost_reassign_failed';
520
        return send_json($client, 409, { error => 'vhost_reassign_failed', detail => clean_scalar($err) });
521
    }
522
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn });
523
}
524

            
Bogdan Timofte authored 4 days ago
525
sub upsert_vhost {
526
    my ($client, $payload) = @_;
527
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
528
    my $target_fqdn = normalize_dns_name($payload->{host_fqdn} || $payload->{fqdn} || '');
529
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
530
    return send_json($client, 400, { error => 'missing_target_host' }) unless $target_fqdn;
531

            
532
    my $dbh = dbh();
533
    return send_json($client, 400, { error => 'invalid_target_host' }) unless db_scalar($dbh, 'SELECT COUNT(*) FROM hosts WHERE fqdn = ? AND status <> ?', $target_fqdn, 'retired');
534
    my ($current_fqdn) = $dbh->selectrow_array(
535
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
536
        undef,
537
        $vhost,
538
    );
539

            
540
    my $result = eval {
541
        with_transaction($dbh, sub {
542
            my $now = iso_now();
543
            upsert_vhost_to_db($dbh, $target_fqdn, $vhost, $now);
544

            
545
            my $registry = load_registry_from_db();
546
            my ($target_host) = grep { ($_->{fqdn} || '') eq $target_fqdn } @{ $registry->{hosts} || [] };
547
            my ($current_host) = grep { ($_->{fqdn} || '') eq ($current_fqdn || '') } @{ $registry->{hosts} || [] };
548

            
549
            upsert_host_to_db($dbh, $target_host) if $target_host;
550
            upsert_host_to_db($dbh, $current_host) if $current_host && ($current_fqdn || '') ne $target_fqdn;
551
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
552
        });
553
        1;
554
    };
555
    if (!$result) {
556
        my $err = $@ || 'vhost_upsert_failed';
557
        return send_json($client, 409, { error => 'vhost_upsert_failed', detail => clean_scalar($err) });
558
    }
559
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, host_fqdn => $target_fqdn, previous_host_fqdn => $current_fqdn || '' });
560
}
561

            
562
sub delete_vhost {
563
    my ($client, $payload) = @_;
564
    my $vhost = normalize_dns_name($payload->{vhost_fqdn} || '');
565
    my $confirm = normalize_dns_name($payload->{confirm} || '');
566
    return send_json($client, 400, { error => 'invalid_vhost' }) unless name_is_vhost($vhost);
567
    return send_json($client, 400, { error => 'confirmation_required' }) unless $confirm eq $vhost;
568

            
569
    my $dbh = dbh();
570
    my ($current_fqdn) = $dbh->selectrow_array(
571
        "SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = 'active'",
572
        undef,
573
        $vhost,
574
    );
575
    return send_json($client, 404, { error => 'vhost_not_found' }) unless $current_fqdn;
576

            
577
    my $result = eval {
578
        with_transaction($dbh, sub {
579
            my $now = iso_now();
580
            $dbh->do(
581
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE vhost_fqdn = ? AND status = 'active'",
582
                undef,
583
                $now, $vhost,
584
            );
585

            
586
            my $registry = load_registry_from_db();
587
            my ($current_host) = grep { ($_->{fqdn} || '') eq $current_fqdn } @{ $registry->{hosts} || [] };
588
            upsert_host_to_db($dbh, $current_host) if $current_host;
589
            set_schema_meta($dbh, 'registry_updated_at', iso_now());
590
        });
591
        1;
592
    };
593
    if (!$result) {
594
        my $err = $@ || 'vhost_delete_failed';
595
        return send_json($client, 409, { error => 'vhost_delete_failed', detail => clean_scalar($err) });
596
    }
597
    return send_json($client, 200, { ok => json_bool(1), vhost_fqdn => $vhost, previous_host_fqdn => $current_fqdn });
598
}
599

            
Xdev Host Manager authored a week ago
600
sub analyze_hosts {
601
    my ($hosts) = @_;
602
    my @problems;
603
    my (%names, %ids);
604
    for my $host (@$hosts) {
605
        push @problems, problem($host, 'duplicate-id', "Duplicate id $host->{id}") if $ids{ $host->{id} }++;
Bogdan Timofte authored 4 days ago
606
        my $fqdn = canonical_host_fqdn($host);
607
        push @problems, problem($host, 'missing-fqdn', 'No madagascar.xdev.ro FQDN') unless ($fqdn =~ /\.madagascar\.xdev\.ro$/) || ($host->{status} || '') ne 'active';
608
        my @declared = declared_dns_names($host);
Xdev Host Manager authored a week ago
609
        push @problems, problem($host, 'deprecated-vad-is', 'Deprecated vad.is.xdev.ro name present')
Bogdan Timofte authored 4 days ago
610
            if grep { /\.vad\.is\.xdev\.ro$/ } @declared;
Xdev Host Manager authored a week ago
611
        push @problems, problem($host, 'legacy-prefix', 'Legacy prefix should be normalized out')
Bogdan Timofte authored 4 days ago
612
            if grep { /^(is|vad|b)-/ } @declared;
613
        for my $name (@declared) {
Xdev Host Manager authored a week ago
614
            push @problems, problem($host, 'duplicate-name', "Duplicate name $name") if $names{$name}++;
615
        }
Bogdan Timofte authored 4 days ago
616
        my %declared = map { $_ => 1 } @declared;
617
        for my $derived (derived_alias_names($host), derived_vhost_alias_names($host)) {
Xdev Host Manager authored a week ago
618
            push @problems, problem($host, 'redundant-derived-name', "Name $derived is derived from madagascar.xdev.ro")
619
                if $declared{$derived};
620
        }
Bogdan Timofte authored 4 days ago
621
        push @problems, problem($host, 'missing-ip', 'Host is missing a canonical routable IP')
622
            unless canonical_ip($host) || ($host->{status} || '') ne 'active';
Xdev Host Manager authored a week ago
623
    }
624
    return \@problems;
625
}
626

            
Xdev Host Manager authored a week ago
627
sub host_payload {
628
    my ($host) = @_;
629
    my %copy = %$host;
Bogdan Timofte authored 4 days ago
630
    $copy{fqdn} = canonical_host_fqdn($host);
631
    $copy{ip} = canonical_ip($host);
Xdev Host Manager authored a week ago
632
    $copy{names} = [ effective_names($host) ];
Bogdan Timofte authored 4 days ago
633
    $copy{declared_names} = [ declared_dns_names($host) ];
634
    $copy{aliases} = [ declared_alias_names($host) ];
635
    $copy{derived_aliases} = [ derived_alias_names($host) ];
636
    $copy{vhosts} = [ declared_vhost_names($host) ];
637
    $copy{derived_vhost_aliases} = [ derived_vhost_alias_names($host) ];
Xdev Host Manager authored a week ago
638
    return \%copy;
639
}
640

            
641
sub effective_names {
642
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
643
    my @names = declared_dns_names($host);
644
    push @names, derived_alias_names($host), derived_vhost_alias_names($host);
Xdev Host Manager authored a week ago
645
    return unique_preserve(@names);
646
}
647

            
Bogdan Timofte authored 4 days ago
648
sub declared_dns_names {
649
    my ($host) = @_;
650
    my @names;
651
    my $fqdn = canonical_host_fqdn($host);
652
    push @names, $fqdn if length $fqdn;
653
    push @names, declared_alias_names($host);
654
    push @names, declared_vhost_names($host);
655
    return unique_preserve(@names);
656
}
657

            
658
sub declared_alias_names {
659
    my ($host) = @_;
660
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{aliases} || [] });
661
}
662

            
663
sub declared_vhost_names {
664
    my ($host) = @_;
665
    return unique_preserve(map { normalize_dns_name($_) } @{ $host->{vhosts} || [] });
666
}
667

            
668
sub declared_dns_names_legacy {
669
    my ($host) = @_;
670
    return map { normalize_dns_name($_) } @{ $host->{names} || [] };
671
}
672

            
673
sub split_legacy_names {
674
    my ($id, $names) = @_;
675
    my $fallback = clean_id($id || '');
676
    my (%result) = (
677
        fqdn => '',
678
        aliases => [],
679
        vhosts => [],
680
    );
681
    for my $name (map { normalize_dns_name($_) } @$names) {
682
        next unless length $name;
683
        if (!$result{fqdn} && $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name)) {
684
            $result{fqdn} = $name;
685
            next;
686
        }
687
        if (!$result{fqdn} && $name =~ /\./ && !name_is_vhost($name)) {
688
            $result{fqdn} = $name;
689
            next;
690
        }
691
        if (name_is_vhost($name)) {
692
            push @{ $result{vhosts} }, $name;
693
        } else {
694
            push @{ $result{aliases} }, $name;
695
        }
696
    }
697
    $result{fqdn} ||= $fallback ? "$fallback.madagascar.xdev.ro" : '';
698
    $result{aliases} = [ unique_preserve(grep { $_ ne $result{fqdn} } @{ $result{aliases} }) ];
699
    $result{vhosts} = [ unique_preserve(@{ $result{vhosts} }) ];
700
    return \%result;
701
}
702

            
703
sub derived_alias_names {
Xdev Host Manager authored a week ago
704
    my ($host) = @_;
705
    my @derived;
Bogdan Timofte authored 4 days ago
706
    my $fqdn = canonical_host_fqdn($host);
707
    push @derived, short_alias_for_fqdn($fqdn) if length $fqdn;
708
    for my $name (declared_alias_names($host)) {
709
        push @derived, short_alias_for_fqdn($name);
710
    }
711
    return unique_preserve(grep { length $_ } @derived);
712
}
713

            
714
sub derived_vhost_alias_names {
715
    my ($host) = @_;
716
    my @derived;
717
    for my $name (declared_vhost_names($host)) {
718
        push @derived, short_alias_for_fqdn($name);
Xdev Host Manager authored a week ago
719
    }
Bogdan Timofte authored 4 days ago
720
    return unique_preserve(grep { length $_ } @derived);
721
}
722

            
723
sub clean_alias_names {
724
    my ($payload) = @_;
725
    return clean_name_bucket($payload->{aliases})
726
        if defined $payload->{aliases};
727
    my @legacy = remove_derived_names(clean_list($payload->{names}));
728
    return grep { !name_is_vhost($_) && $_ ne canonical_host_fqdn({ %$payload, names => \@legacy }) } @legacy;
729
}
730

            
731
sub clean_vhost_names {
732
    my ($payload) = @_;
733
    return clean_name_bucket($payload->{vhosts})
734
        if defined $payload->{vhosts};
735
    my @legacy = remove_derived_names(clean_list($payload->{names}));
736
    return grep { name_is_vhost($_) } @legacy;
737
}
738

            
739
sub clean_name_bucket {
740
    my ($value) = @_;
741
    my @names = clean_list($value);
742
    return unique_preserve(map { normalize_dns_name($_) } remove_derived_names(@names));
Xdev Host Manager authored a week ago
743
}
744

            
745
sub remove_derived_names {
746
    my @names = @_;
747
    my %derived;
748
    for my $name (@names) {
749
        next unless $name =~ /^(.+)\.madagascar\.xdev\.ro$/;
750
        $derived{$1} = 1;
751
    }
752
    return grep { !$derived{$_} } @names;
753
}
754

            
755
sub unique_preserve {
756
    my @values = @_;
757
    my %seen;
758
    return grep { !$seen{$_}++ } @values;
759
}
760

            
Bogdan Timofte authored 4 days ago
761
sub canonical_ip {
762
    my ($host) = @_;
763
    return '' unless $host && ref($host) eq 'HASH';
764
    for my $key (qw(ip dns_ip hosts_ip)) {
765
        my $value = clean_scalar($host->{$key} || '');
766
        return $value if length $value;
767
    }
768
    return '';
769
}
770

            
Xdev Host Manager authored a week ago
771
sub problem {
772
    my ($host, $code, $message) = @_;
773
    return { host_id => $host->{id}, code => $code, message => $message };
774
}
775

            
776
sub render_local_hosts_tsv {
777
    my ($registry) = @_;
778
    my $out = "# Local DNS manifest for the madagascar network.\n";
Bogdan Timofte authored 4 days ago
779
    $out .= "# Generated by scripts/host_manager.pl from the runtime SQLite registry.\n";
Xdev Host Manager authored a week ago
780
    $out .= "#\n";
781
    $out .= "# Format:\n";
Bogdan Timofte authored 4 days ago
782
    $out .= "# ip<TAB>name [aliases...]\n";
Xdev Host Manager authored a week ago
783
    $out .= "#\n";
784
    $out .= "# Priority rule:\n";
785
    $out .= "# - DHCP lease/reservation on 192.168.2.1 is canonical for LAN IP allocation.\n";
786
    $out .= "# - madagascar.json is canonical for cluster roles and service interfaces.\n";
787
    $out .= "# - This file publishes approved local DNS records derived from those sources.\n";
788
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
789
        next unless ($host->{status} || 'active') eq 'active';
Bogdan Timofte authored 4 days ago
790
        my $ip = canonical_ip($host);
791
        next unless $ip;
Xdev Host Manager authored a week ago
792
        my @names = effective_names($host);
793
        next unless @names;
Bogdan Timofte authored 4 days ago
794
        $out .= join("\t", $ip, join(' ', @names)) . "\n";
Xdev Host Manager authored a week ago
795
    }
796
    return $out;
797
}
798

            
799
sub render_monitoring {
800
    my ($registry) = @_;
801
    my @hosts;
802
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} }) {
803
        next unless ($host->{status} || 'active') eq 'active';
804
        next if ($host->{monitoring} || 'pending') eq 'disabled';
Xdev Host Manager authored a week ago
805
        my @names = effective_names($host);
Xdev Host Manager authored a week ago
806
        push @hosts, {
807
            id => $host->{id},
Xdev Host Manager authored a week ago
808
            primary_name => $names[0],
Bogdan Timofte authored 4 days ago
809
            address => canonical_ip($host),
Xdev Host Manager authored a week ago
810
            aliases => \@names,
Bogdan Timofte authored 4 days ago
811
            fqdn => canonical_host_fqdn($host),
812
            declared_names => [ declared_dns_names($host) ],
813
            aliases_declared => [ declared_alias_names($host) ],
814
            aliases_derived => [ derived_alias_names($host) ],
815
            vhosts_declared => [ declared_vhost_names($host) ],
816
            vhost_aliases_derived => [ derived_vhost_alias_names($host) ],
Xdev Host Manager authored a week ago
817
            roles => [ @{ $host->{roles} || [] } ],
818
            monitoring => $host->{monitoring} || 'pending',
819
            notes => $host->{notes} || '',
820
        };
821
    }
822
    return {
823
        version => $registry->{version},
824
        generated_at => iso_now(),
Bogdan Timofte authored 4 days ago
825
        source => $opt{db},
Xdev Host Manager authored a week ago
826
        hosts => \@hosts,
827
    };
828
}
829

            
Bogdan Timofte authored 4 days ago
830
sub debug_database_tables_payload {
831
    my $dbh = dbh();
832
    my @tables;
833
    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
834
    $sth->execute;
835
    while (my ($name) = $sth->fetchrow_array) {
836
        my $quoted = $dbh->quote_identifier($name);
837
        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
838
        push @tables, {
839
            name => $name,
840
            rows => int($count || 0),
841
        };
842
    }
843
    return {
844
        database => $opt{db},
845
        generated_at => iso_now(),
846
        tables => \@tables,
847
        counts => {
848
            tables => scalar @tables,
849
            rows => sum(map { $_->{rows} } @tables),
850
        },
851
    };
852
}
853

            
854
sub debug_database_table_payload {
855
    my ($table, $limit) = @_;
856
    my $dbh = dbh();
857
    $table = clean_scalar($table);
858
    return { error => 'missing_table' } unless length $table;
859
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
860
    $limit = int($limit || 100);
861
    $limit = 1 if $limit < 1;
862
    $limit = 500 if $limit > 500;
863

            
864
    my $quoted = $dbh->quote_identifier($table);
865
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
866
    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
867
    my @index_details;
868
    for my $index (@$indexes) {
869
        my $index_name = $index->{name} || '';
870
        next unless length $index_name;
871
        my $quoted_index = $dbh->quote_identifier($index_name);
872
        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
873
        push @index_details, {
874
            name => $index_name,
875
            unique => int($index->{unique} || 0),
876
            origin => $index->{origin} || '',
877
            partial => int($index->{partial} || 0),
878
            columns => [ map { $_->{name} || '' } @$index_columns ],
879
        };
880
    }
881
    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
882
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
883
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
884

            
885
    return {
886
        database => $opt{db},
887
        table => $table,
888
        generated_at => iso_now(),
889
        limit => $limit,
890
        row_count => int($row_count || 0),
891
        columns => $columns,
892
        indexes => \@index_details,
893
        foreign_keys => $foreign_keys,
894
        rows => $rows,
895
    };
896
}
897

            
Bogdan Timofte authored 4 days ago
898
sub debug_database_table_export_payload {
899
    my ($table) = @_;
900
    my $dbh = dbh();
901
    $table = clean_scalar($table);
902
    return { error => 'missing_table' } unless length $table;
903
    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
904

            
905
    my $quoted = $dbh->quote_identifier($table);
906
    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
907
    my @column_names = map { $_->{name} || '' } @$columns;
908
    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
909
    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
910

            
911
    return {
912
        database => $opt{db},
913
        table => $table,
914
        generated_at => iso_now(),
915
        row_count => int($row_count || 0),
916
        columns => \@column_names,
917
        rows => $rows,
918
    };
919
}
920

            
921
sub render_debug_table_csv {
922
    my ($export) = @_;
923
    my @columns = @{ $export->{columns} || [] };
924
    my @lines = (join(',', map { csv_cell($_) } @columns));
925
    for my $row (@{ $export->{rows} || [] }) {
926
        push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
927
    }
928
    return join("\n", @lines) . "\n";
929
}
930

            
931
sub csv_cell {
932
    my ($value) = @_;
933
    $value = '' unless defined $value;
934
    $value = "$value";
935
    $value =~ s/"/""/g;
936
    return qq("$value") if $value =~ /[",\r\n]/;
937
    return $value;
938
}
939

            
940
sub debug_table_export_filename {
941
    my ($table, $extension) = @_;
942
    $table = clean_scalar($table || 'table');
943
    $table =~ s/[^A-Za-z0-9_.-]+/-/g;
944
    $table = 'table' unless length $table;
945
    return "debug-$table.$extension";
946
}
947

            
Bogdan Timofte authored 4 days ago
948
sub debug_table_exists {
949
    my ($dbh, $table) = @_;
950
    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
951
    my ($exists) = $dbh->selectrow_array(
952
        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
953
        undef,
954
        $table,
955
    );
956
    return $exists ? 1 : 0;
957
}
958

            
959
sub sum {
960
    my $total = 0;
961
    $total += $_ || 0 for @_;
962
    return $total;
963
}
964

            
Xdev Host Manager authored a week ago
965
sub ca_script_path {
966
    return "$project_dir/scripts/ca_manager.sh";
967
}
968

            
969
sub ca_dir {
970
    return $ENV{HOST_MANAGER_CA_DIR} || "$project_dir/var/ca";
971
}
972

            
973
sub ca_cert_path {
974
    return ca_dir() . "/certs/ca.cert.pem";
975
}
976

            
Bogdan Timofte authored 5 days ago
977
sub ca_issued_cert_path {
978
    my ($name) = @_;
979
    die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/;
980
    return ca_dir() . "/issued/$name.cert.pem";
981
}
982

            
Xdev Host Manager authored a week ago
983
sub ca_manager_json {
984
    my ($command) = @_;
985
    my $script = ca_script_path();
986
    die "CA manager script is missing\n" unless -x $script;
987
    local $ENV{HOST_MANAGER_CA_DIR} = ca_dir();
988
    open my $fh, '-|', $script, $command or die "Cannot run CA manager\n";
989
    local $/;
990
    my $out = <$fh>;
991
    close $fh or die "CA manager failed\n";
Bogdan Timofte authored 4 days ago
992
    $out ||= $command eq 'list-json' ? '[]' : '{}';
993
    sync_certificates_from_json($out) if $command eq 'list-json';
994
    return $out;
995
}
996

            
997
sub sync_certificates_from_json {
998
    my ($json) = @_;
999
    my $certs = eval { json_decode($json || '[]') };
1000
    return if $@ || ref($certs) ne 'ARRAY';
1001
    my $dbh = dbh();
1002
    my $now = iso_now();
1003
    with_transaction($dbh, sub {
1004
        for my $cert (@$certs) {
1005
            next unless ref($cert) eq 'HASH';
1006
            my $name = clean_id($cert->{name} || $cert->{serial} || $cert->{fingerprint_sha256} || '');
1007
            next unless $name;
1008
            my @dns_names = map { normalize_dns_name($_) } @{ $cert->{dns_names} || [] };
1009
            my $host_fqdn = infer_certificate_host_fqdn($dbh, \@dns_names);
1010
            my $cert_path = ca_issued_cert_path($name);
1011
            my $csr_path = ca_dir() . "/csr/$name.csr.pem";
1012
            my $serial = clean_scalar($cert->{serial} || '');
1013
            my $fingerprint = clean_scalar($cert->{fingerprint_sha256} || '');
1014
            $dbh->do(
1015
                'INSERT INTO certificates (certificate_id, host_fqdn, common_name, subject, issuer, serial, status, not_before, not_after, fingerprint_sha256, cert_path, csr_path, created_at, updated_at, notes) '
1016
                . "VALUES (?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, '') "
1017
                . 'ON CONFLICT(certificate_id) DO UPDATE SET host_fqdn = excluded.host_fqdn, common_name = excluded.common_name, '
1018
                . 'subject = excluded.subject, issuer = excluded.issuer, serial = excluded.serial, status = excluded.status, '
1019
                . 'not_before = excluded.not_before, not_after = excluded.not_after, fingerprint_sha256 = excluded.fingerprint_sha256, '
1020
                . 'cert_path = excluded.cert_path, csr_path = excluded.csr_path, updated_at = excluded.updated_at',
1021
                undef,
1022
                $name,
1023
                $host_fqdn || undef,
1024
                $dns_names[0] || '',
1025
                clean_scalar($cert->{subject} || ''),
1026
                clean_scalar($cert->{issuer} || ''),
1027
                length($serial) ? $serial : undef,
1028
                clean_scalar($cert->{not_before} || ''),
1029
                clean_scalar($cert->{not_after} || ''),
1030
                length($fingerprint) ? $fingerprint : undef,
1031
                $cert_path,
1032
                $csr_path,
1033
                $now,
1034
                $now,
1035
            );
1036
            $dbh->do('DELETE FROM certificate_dns_names WHERE certificate_id = ?', undef, $name);
1037
            for my $dns_name (@dns_names) {
1038
                next unless length $dns_name;
1039
                $dbh->do(
1040
                    'INSERT OR IGNORE INTO certificate_dns_names (certificate_id, dns_name) VALUES (?, ?)',
1041
                    undef,
1042
                    $name,
1043
                    $dns_name,
1044
                );
1045
            }
1046
        }
1047
    });
1048
}
1049

            
1050
sub infer_certificate_host_fqdn {
1051
    my ($dbh, $dns_names) = @_;
1052
    for my $name (@$dns_names) {
1053
        my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE fqdn = ?', undef, $name);
1054
        return $fqdn if $fqdn;
1055
    }
1056
    for my $name (@$dns_names) {
1057
        my ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = ?', undef, $name, 'active');
1058
        return $fqdn if $fqdn;
1059
        ($fqdn) = $dbh->selectrow_array('SELECT host_fqdn FROM vhosts WHERE vhost_fqdn = ? AND status = ?', undef, $name, 'active');
1060
        return $fqdn if $fqdn;
1061
    }
1062
    return '';
Xdev Host Manager authored a week ago
1063
}
1064

            
Xdev Host Manager authored a week ago
1065
sub parse_hosts_yaml {
1066
    my ($text) = @_;
1067
    my %registry = (
1068
        version => 1,
1069
        updated_at => '',
1070
        policy => {},
1071
        hosts => [],
1072
    );
1073
    my ($section, $current, $list_key);
1074
    for my $line (split /\n/, $text) {
1075
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1076
        if ($line =~ /^version:\s*(\d+)/) {
1077
            $registry{version} = int($1);
1078
        } elsif ($line =~ /^updated_at:\s*(.+)$/) {
1079
            $registry{updated_at} = yaml_unquote($1);
1080
        } elsif ($line =~ /^policy:\s*$/) {
1081
            $section = 'policy';
1082
        } elsif ($line =~ /^hosts:\s*$/) {
1083
            $section = 'hosts';
1084
        } elsif (($section || '') eq 'policy' && $line =~ /^  ([A-Za-z0-9_]+):\s*(.+)$/) {
1085
            $registry{policy}{$1} = yaml_unquote($2);
1086
        } elsif (($section || '') eq 'hosts' && $line =~ /^  - id:\s*(.+)$/) {
1087
            $current = {
1088
                id => yaml_unquote($1),
Bogdan Timofte authored 4 days ago
1089
                fqdn => '',
Xdev Host Manager authored a week ago
1090
                status => 'active',
Bogdan Timofte authored 4 days ago
1091
                ip => '',
1092
                aliases => [],
1093
                vhosts => [],
Xdev Host Manager authored a week ago
1094
                roles => [],
1095
                sources => [],
1096
                monitoring => 'pending',
1097
                notes => '',
1098
            };
1099
            push @{ $registry{hosts} }, $current;
1100
            $list_key = undef;
1101
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*$/) {
1102
            $list_key = $1;
1103
            $current->{$list_key} ||= [];
1104
        } elsif ($current && defined $list_key && $line =~ /^      -\s*(.+)$/) {
1105
            push @{ $current->{$list_key} }, yaml_unquote($1);
1106
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
Bogdan Timofte authored 4 days ago
1107
            my $key = $1;
1108
            my $value = yaml_unquote($2);
1109
            if ($key eq 'ip') {
1110
                $current->{ip} = $value;
1111
            } elsif ($key eq 'dns_ip' || $key eq 'hosts_ip') {
1112
                $current->{ip} ||= $value;
1113
            } elsif ($key eq 'fqdn') {
1114
                $current->{fqdn} = normalize_dns_name($value);
1115
            } elsif ($key eq 'names') {
1116
                # ignored here; legacy list is handled after parsing
1117
            } else {
1118
                $current->{$key} = $value;
1119
            }
Xdev Host Manager authored a week ago
1120
            $list_key = undef;
1121
        }
1122
    }
Bogdan Timofte authored 4 days ago
1123
    for my $host (@{ $registry{hosts} }) {
1124
        my @legacy_names = @{ $host->{names} || [] };
1125
        if (@legacy_names) {
1126
            my $legacy = split_legacy_names($host->{id}, \@legacy_names);
1127
            $host->{fqdn} ||= $legacy->{fqdn};
1128
            $host->{aliases} = $legacy->{aliases} unless @{ $host->{aliases} || [] };
1129
            $host->{vhosts} = $legacy->{vhosts} unless @{ $host->{vhosts} || [] };
1130
        }
1131
        delete $host->{names};
1132
        $host->{fqdn} ||= canonical_host_fqdn($host);
1133
    }
Xdev Host Manager authored a week ago
1134
    return \%registry;
1135
}
1136

            
1137
sub render_hosts_yaml {
1138
    my ($registry) = @_;
1139
    my $out = "version: " . int($registry->{version} || 1) . "\n";
1140
    $out .= "updated_at: " . yq($registry->{updated_at} || iso_now()) . "\n";
1141
    $out .= "policy:\n";
1142
    for my $key (sort keys %{ $registry->{policy} || {} }) {
1143
        $out .= "  $key: " . yq($registry->{policy}{$key}) . "\n";
1144
    }
1145
    $out .= "hosts:\n";
1146
    for my $host (sort { $a->{id} cmp $b->{id} } @{ $registry->{hosts} || [] }) {
1147
        $out .= "  - id: " . yq($host->{id}) . "\n";
Bogdan Timofte authored 4 days ago
1148
        $out .= "    fqdn: " . yq(canonical_host_fqdn($host)) . "\n";
1149
        $out .= "    status: " . yq($host->{status} || '') . "\n";
1150
        $out .= "    ip: " . yq(canonical_ip($host)) . "\n";
1151
        for my $key (qw(aliases vhosts roles sources)) {
Xdev Host Manager authored a week ago
1152
            $out .= "    $key:\n";
1153
            for my $value (@{ $host->{$key} || [] }) {
1154
                $out .= "      - " . yq($value) . "\n";
1155
            }
1156
        }
1157
        $out .= "    monitoring: " . yq($host->{monitoring} || 'pending') . "\n";
1158
        $out .= "    notes: " . yq($host->{notes} || '') . "\n";
1159
    }
1160
    return $out;
1161
}
1162

            
Xdev Host Manager authored a week ago
1163
sub parse_work_orders_yaml {
1164
    my ($text) = @_;
1165
    my %orders = (
1166
        version => 1,
1167
        work_orders => [],
1168
    );
Xdev Host Manager authored a week ago
1169
    my ($section, $current, $list_section, $current_action, $current_item);
Xdev Host Manager authored a week ago
1170
    for my $line (split /\n/, $text) {
1171
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
1172
        if ($line =~ /^version:\s*(\d+)/) {
1173
            $orders{version} = int($1);
1174
        } elsif ($line =~ /^work_orders:\s*$/) {
1175
            $section = 'work_orders';
1176
        } elsif (($section || '') eq 'work_orders' && $line =~ /^  - id:\s*(.+)$/) {
1177
            $current = {
1178
                id => yaml_unquote($1),
1179
                status => 'pending',
Xdev Host Manager authored a week ago
1180
                checklist => [],
Xdev Host Manager authored a week ago
1181
                actions => [],
1182
            };
1183
            push @{ $orders{work_orders} }, $current;
Xdev Host Manager authored a week ago
1184
            $list_section = '';
Xdev Host Manager authored a week ago
1185
            $current_action = undef;
Xdev Host Manager authored a week ago
1186
            $current_item = undef;
1187
        } elsif ($current && $line =~ /^    checklist:\s*$/) {
1188
            $list_section = 'checklist';
1189
            $current->{checklist} ||= [];
1190
        } elsif ($current && $list_section eq 'checklist' && $line =~ /^      - id:\s*(.+)$/) {
1191
            $current_item = { id => yaml_unquote($1), status => 'pending' };
1192
            push @{ $current->{checklist} }, $current_item;
1193
            $current_action = undef;
1194
        } elsif ($current_item && $list_section eq 'checklist' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
1195
            $current_item->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1196
        } elsif ($current && $line =~ /^    actions:\s*$/) {
Xdev Host Manager authored a week ago
1197
            $list_section = 'actions';
Xdev Host Manager authored a week ago
1198
            $current->{actions} ||= [];
Xdev Host Manager authored a week ago
1199
        } elsif ($current && $list_section eq 'actions' && $line =~ /^      - type:\s*(.+)$/) {
Xdev Host Manager authored a week ago
1200
            $current_action = { type => yaml_unquote($1) };
1201
            push @{ $current->{actions} }, $current_action;
Xdev Host Manager authored a week ago
1202
            $current_item = undef;
1203
        } elsif ($current_action && $list_section eq 'actions' && $line =~ /^        ([A-Za-z0-9_]+):\s*(.*)$/) {
Xdev Host Manager authored a week ago
1204
            $current_action->{$1} = yaml_unquote($2);
1205
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
1206
            $current->{$1} = yaml_unquote($2);
Xdev Host Manager authored a week ago
1207
            $list_section = '';
Xdev Host Manager authored a week ago
1208
            $current_action = undef;
Xdev Host Manager authored a week ago
1209
            $current_item = undef;
Xdev Host Manager authored a week ago
1210
        }
1211
    }
1212
    return \%orders;
1213
}
1214

            
1215
sub render_work_orders_yaml {
1216
    my ($orders) = @_;
1217
    my $out = "version: " . int($orders->{version} || 1) . "\n";
1218
    $out .= "work_orders:\n";
1219
    for my $wo (@{ $orders->{work_orders} || [] }) {
1220
        $out .= "  - id: " . yq($wo->{id}) . "\n";
1221
        for my $key (qw(status title reason created_at confirmed_at result)) {
1222
            next unless exists $wo->{$key} && length($wo->{$key} || '');
1223
            $out .= "    $key: " . yq($wo->{$key}) . "\n";
1224
        }
Xdev Host Manager authored a week ago
1225
        $out .= "    checklist:\n";
1226
        for my $item (@{ $wo->{checklist} || [] }) {
1227
            $out .= "      - id: " . yq($item->{id}) . "\n";
1228
            for my $key (qw(text status owner notes updated_at)) {
1229
                next unless exists $item->{$key} && length($item->{$key} || '');
1230
                $out .= "        $key: " . yq($item->{$key}) . "\n";
1231
            }
1232
        }
Xdev Host Manager authored a week ago
1233
        $out .= "    actions:\n";
1234
        for my $action (@{ $wo->{actions} || [] }) {
1235
            $out .= "      - type: " . yq($action->{type}) . "\n";
1236
            for my $key (qw(host_id name)) {
1237
                next unless exists $action->{$key} && length($action->{$key} || '');
1238
                $out .= "        $key: " . yq($action->{$key}) . "\n";
1239
            }
1240
        }
1241
    }
1242
    return $out;
1243
}
1244

            
Xdev Host Manager authored a week ago
1245
sub request_payload {
1246
    my ($headers, $body) = @_;
1247
    my $type = $headers->{'content-type'} || '';
1248
    if ($type =~ m{application/json}) {
1249
        return json_decode($body || '{}');
1250
    }
1251
    return { parse_params($body || '') };
1252
}
1253

            
1254
sub json_bool {
1255
    my ($value) = @_;
1256
    return bless \(my $bool = $value ? 1 : 0), 'HostManager::JSONBool';
1257
}
1258

            
1259
sub json_encode {
1260
    my ($value) = @_;
1261
    if (!defined $value) {
1262
        return 'null';
1263
    }
1264
    my $ref = ref($value);
1265
    if (!$ref) {
1266
        return $value if $value =~ /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?\z/;
1267
        return json_string($value);
1268
    }
1269
    if ($ref eq 'HostManager::JSONBool') {
1270
        return $$value ? 'true' : 'false';
1271
    }
1272
    if ($ref eq 'ARRAY') {
1273
        return '[' . join(',', map { json_encode($_) } @$value) . ']';
1274
    }
1275
    if ($ref eq 'HASH') {
1276
        return '{' . join(',', map { json_string($_) . ':' . json_encode($value->{$_}) } sort keys %$value) . '}';
1277
    }
1278
    return json_string("$value");
1279
}
1280

            
1281
sub json_string {
1282
    my ($value) = @_;
1283
    $value = '' unless defined $value;
1284
    $value =~ s/\\/\\\\/g;
1285
    $value =~ s/"/\\"/g;
1286
    $value =~ s/\n/\\n/g;
1287
    $value =~ s/\r/\\r/g;
1288
    $value =~ s/\t/\\t/g;
1289
    $value =~ s/([\x00-\x1f])/sprintf("\\u%04x", ord($1))/eg;
1290
    return qq("$value");
1291
}
1292

            
1293
sub json_decode {
1294
    my ($text) = @_;
1295
    my $i = 0;
1296
    my $len = length($text);
1297
    my ($parse_value, $parse_string, $parse_array, $parse_object, $parse_number, $skip_ws);
1298

            
1299
    $skip_ws = sub {
1300
        $i++ while $i < $len && substr($text, $i, 1) =~ /\s/;
1301
    };
1302

            
1303
    $parse_string = sub {
1304
        die "Expected JSON string\n" unless substr($text, $i, 1) eq '"';
1305
        $i++;
1306
        my $out = '';
1307
        while ($i < $len) {
1308
            my $ch = substr($text, $i++, 1);
1309
            return $out if $ch eq '"';
1310
            if ($ch eq "\\") {
1311
                die "Bad JSON escape\n" if $i >= $len;
1312
                my $esc = substr($text, $i++, 1);
1313
                if ($esc eq '"' || $esc eq "\\" || $esc eq '/') {
1314
                    $out .= $esc;
1315
                } elsif ($esc eq 'b') {
1316
                    $out .= "\b";
1317
                } elsif ($esc eq 'f') {
1318
                    $out .= "\f";
1319
                } elsif ($esc eq 'n') {
1320
                    $out .= "\n";
1321
                } elsif ($esc eq 'r') {
1322
                    $out .= "\r";
1323
                } elsif ($esc eq 't') {
1324
                    $out .= "\t";
1325
                } elsif ($esc eq 'u') {
1326
                    my $hex = substr($text, $i, 4);
1327
                    die "Bad JSON unicode escape\n" unless $hex =~ /\A[0-9A-Fa-f]{4}\z/;
1328
                    $out .= chr(hex($hex));
1329
                    $i += 4;
1330
                } else {
1331
                    die "Bad JSON escape\n";
1332
                }
1333
            } else {
1334
                $out .= $ch;
1335
            }
1336
        }
1337
        die "Unterminated JSON string\n";
1338
    };
1339

            
1340
    $parse_number = sub {
1341
        my $start = $i;
1342
        $i++ if substr($text, $i, 1) eq '-';
1343
        $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1344
        if ($i < $len && substr($text, $i, 1) eq '.') {
1345
            $i++;
1346
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1347
        }
1348
        if ($i < $len && substr($text, $i, 1) =~ /[eE]/) {
1349
            $i++;
1350
            $i++ if $i < $len && substr($text, $i, 1) =~ /[+-]/;
1351
            $i++ while $i < $len && substr($text, $i, 1) =~ /[0-9]/;
1352
        }
1353
        return 0 + substr($text, $start, $i - $start);
1354
    };
1355

            
1356
    $parse_array = sub {
1357
        die "Expected JSON array\n" unless substr($text, $i, 1) eq '[';
1358
        $i++;
1359
        my @out;
1360
        $skip_ws->();
1361
        if ($i < $len && substr($text, $i, 1) eq ']') {
1362
            $i++;
1363
            return \@out;
1364
        }
1365
        while (1) {
1366
            push @out, $parse_value->();
1367
            $skip_ws->();
1368
            my $ch = substr($text, $i++, 1);
1369
            last if $ch eq ']';
1370
            die "Expected JSON array comma\n" unless $ch eq ',';
1371
        }
1372
        return \@out;
1373
    };
1374

            
1375
    $parse_object = sub {
1376
        die "Expected JSON object\n" unless substr($text, $i, 1) eq '{';
1377
        $i++;
1378
        my %out;
1379
        $skip_ws->();
1380
        if ($i < $len && substr($text, $i, 1) eq '}') {
1381
            $i++;
1382
            return \%out;
1383
        }
1384
        while (1) {
1385
            $skip_ws->();
1386
            my $key = $parse_string->();
1387
            $skip_ws->();
1388
            die "Expected JSON object colon\n" unless substr($text, $i++, 1) eq ':';
1389
            $out{$key} = $parse_value->();
1390
            $skip_ws->();
1391
            my $ch = substr($text, $i++, 1);
1392
            last if $ch eq '}';
1393
            die "Expected JSON object comma\n" unless $ch eq ',';
1394
        }
1395
        return \%out;
1396
    };
1397

            
1398
    $parse_value = sub {
1399
        $skip_ws->();
1400
        die "Unexpected end of JSON\n" if $i >= $len;
1401
        my $ch = substr($text, $i, 1);
1402
        return $parse_string->() if $ch eq '"';
1403
        return $parse_object->() if $ch eq '{';
1404
        return $parse_array->() if $ch eq '[';
1405
        if (substr($text, $i, 4) eq 'true') {
1406
            $i += 4;
1407
            return json_bool(1);
1408
        }
1409
        if (substr($text, $i, 5) eq 'false') {
1410
            $i += 5;
1411
            return json_bool(0);
1412
        }
1413
        if (substr($text, $i, 4) eq 'null') {
1414
            $i += 4;
1415
            return undef;
1416
        }
1417
        return $parse_number->() if $ch =~ /[-0-9]/;
1418
        die "Unexpected JSON token\n";
1419
    };
1420

            
1421
    my $value = $parse_value->();
1422
    $skip_ws->();
1423
    die "Trailing JSON content\n" if $i != $len;
1424
    return $value;
1425
}
1426

            
1427
sub parse_params {
1428
    my ($text) = @_;
1429
    my %out;
1430
    for my $pair (split /&/, $text) {
1431
        next unless length $pair;
1432
        my ($k, $v) = split /=/, $pair, 2;
1433
        $out{url_decode($k)} = url_decode($v || '');
1434
    }
1435
    return %out;
1436
}
1437

            
1438
sub clean_id {
1439
    my ($value) = @_;
1440
    $value = lc clean_scalar($value);
1441
    $value =~ s/[^a-z0-9_.-]+/-/g;
1442
    $value =~ s/^-+|-+$//g;
1443
    return $value;
1444
}
1445

            
1446
sub clean_scalar {
1447
    my ($value) = @_;
1448
    $value = '' unless defined $value;
1449
    $value =~ s/[\r\n\t]+/ /g;
1450
    $value =~ s/^\s+|\s+$//g;
1451
    return $value;
1452
}
1453

            
1454
sub clean_list {
1455
    my ($value) = @_;
1456
    return () unless defined $value;
1457
    my @items = ref($value) eq 'ARRAY' ? @$value : split /[\s,]+/, $value;
1458
    my @clean;
1459
    for my $item (@items) {
1460
        $item = clean_scalar($item);
1461
        push @clean, $item if length $item;
1462
    }
1463
    return @clean;
1464
}
1465

            
1466
sub yq {
1467
    my ($value) = @_;
1468
    $value = '' unless defined $value;
1469
    $value =~ s/\\/\\\\/g;
1470
    $value =~ s/"/\\"/g;
1471
    return qq("$value");
1472
}
1473

            
1474
sub yaml_unquote {
1475
    my ($value) = @_;
1476
    $value = '' unless defined $value;
1477
    $value =~ s/^\s+|\s+$//g;
1478
    if ($value =~ /^"(.*)"$/) {
1479
        $value = $1;
1480
        $value =~ s/\\"/"/g;
1481
        $value =~ s/\\\\/\\/g;
1482
    }
1483
    return $value;
1484
}
1485

            
1486
sub verify_totp {
1487
    my ($secret, $otp) = @_;
1488
    return 0 unless $secret && $otp =~ /^\d{6}$/;
1489
    my $key = eval { base32_decode($secret) };
1490
    return 0 if $@ || !length $key;
1491
    my $counter = int(time() / 30);
1492
    for my $offset (-1, 0, 1) {
1493
        return 1 if totp_code($key, $counter + $offset) eq $otp;
1494
    }
1495
    return 0;
1496
}
1497

            
1498
sub totp_code {
1499
    my ($key, $counter) = @_;
1500
    my $msg = pack('NN', int($counter / 4294967296), $counter & 0xffffffff);
1501
    my $hash = hmac_sha1($msg, $key);
1502
    my $offset = ord(substr($hash, -1)) & 0x0f;
1503
    my $bin = unpack('N', substr($hash, $offset, 4)) & 0x7fffffff;
1504
    return sprintf('%06d', $bin % 1_000_000);
1505
}
1506

            
1507
sub base32_decode {
1508
    my ($text) = @_;
1509
    $text = uc($text || '');
1510
    $text =~ s/[^A-Z2-7]//g;
1511
    my %map;
1512
    my @chars = ('A'..'Z', '2'..'7');
1513
    @map{@chars} = (0..31);
1514
    my ($bits, $value, $out) = (0, 0, '');
1515
    for my $char (split //, $text) {
1516
        die "Invalid base32\n" unless exists $map{$char};
1517
        $value = ($value << 5) | $map{$char};
1518
        $bits += 5;
1519
        while ($bits >= 8) {
1520
            $bits -= 8;
1521
            $out .= chr(($value >> $bits) & 0xff);
1522
        }
1523
    }
1524
    return $out;
1525
}
1526

            
1527
sub create_session {
1528
    my $nonce = random_hex(24);
1529
    my $expires = int(time() + 8 * 3600);
1530
    my $sig = hmac_sha256_hex("$nonce:$expires", $session_secret);
1531
    my $token = "$nonce:$expires:$sig";
1532
    $sessions{$token} = $expires;
1533
    return $token;
1534
}
1535

            
1536
sub is_authenticated {
1537
    my ($headers) = @_;
1538
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1539
    return 0 unless $token;
1540
    my ($nonce, $expires, $sig) = split /:/, $token;
1541
    return 0 unless $nonce && $expires && $sig;
1542
    return 0 if $expires < time();
1543
    return 0 unless hmac_sha256_hex("$nonce:$expires", $session_secret) eq $sig;
1544
    return exists $sessions{$token};
1545
}
1546

            
1547
sub expire_session {
1548
    my ($headers) = @_;
1549
    my $token = cookie_value($headers->{'cookie'} || '', 'hm_session');
1550
    delete $sessions{$token} if $token;
1551
}
1552

            
1553
sub cookie_value {
1554
    my ($cookie, $name) = @_;
1555
    for my $part (split /;\s*/, $cookie) {
1556
        my ($k, $v) = split /=/, $part, 2;
1557
        return $v if defined $k && $k eq $name;
1558
    }
1559
    return '';
1560
}
1561

            
1562
sub send_json {
1563
    my ($client, $status, $payload, $extra_headers) = @_;
1564
    return send_response($client, $status, json_encode($payload), 'application/json; charset=utf-8', $extra_headers);
1565
}
1566

            
Xdev Host Manager authored a week ago
1567
sub send_json_raw {
1568
    my ($client, $status, $json_body, $extra_headers) = @_;
1569
    return send_response($client, $status, $json_body, 'application/json; charset=utf-8', $extra_headers);
1570
}
1571

            
Xdev Host Manager authored a week ago
1572
sub send_html {
1573
    my ($client, $status, $html) = @_;
1574
    return send_response($client, $status, $html, 'text/html; charset=utf-8');
1575
}
1576

            
1577
sub send_text {
1578
    my ($client, $status, $text) = @_;
1579
    return send_response($client, $status, $text, 'text/plain; charset=utf-8');
1580
}
1581

            
1582
sub send_download {
1583
    my ($client, $status, $content, $type, $filename) = @_;
1584
    return send_response($client, $status, $content, $type, [ qq(Content-Disposition: attachment; filename="$filename") ]);
1585
}
1586

            
1587
sub send_file {
1588
    my ($client, $path, $type, $filename) = @_;
1589
    return send_json($client, 404, { error => 'missing_file' }) unless -f $path;
1590
    return send_download($client, 200, read_file($path), $type, $filename);
1591
}
1592

            
1593
sub send_response {
1594
    my ($client, $status, $body, $type, $extra_headers) = @_;
Xdev Host Manager authored a week ago
1595
    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
1596
    $body = '' unless defined $body;
1597
    print $client "HTTP/1.1 $status " . ($reason{$status} || 'OK') . "\r\n";
1598
    print $client "Content-Type: $type\r\n";
1599
    print $client "Content-Length: " . length($body) . "\r\n";
1600
    print $client "Cache-Control: no-store\r\n";
1601
    print $client "$_\r\n" for @{ $extra_headers || [] };
1602
    print $client "Connection: close\r\n\r\n";
1603
    print $client $body;
1604
}
1605

            
1606
sub read_file {
1607
    my ($path) = @_;
1608
    open my $fh, '<', $path or die "Cannot read $path: $!";
1609
    local $/;
1610
    return <$fh>;
1611
}
1612

            
1613
sub write_file {
1614
    my ($path, $content) = @_;
1615
    open my $fh, '>', $path or die "Cannot write $path: $!";
1616
    print {$fh} $content;
1617
    close $fh or die "Cannot close $path: $!";
1618
}
1619

            
1620
sub backup_file {
1621
    my ($path) = @_;
1622
    return unless -f $path;
1623
    my $backup_dir = "$project_dir/backups/host-manager";
1624
    make_path($backup_dir) unless -d $backup_dir;
1625
    my $name = $path;
1626
    $name =~ s{.*/}{};
1627
    my $stamp = strftime('%Y%m%d_%H%M%S', localtime);
1628
    write_file("$backup_dir/$name.$stamp.bak", read_file($path));
1629
}
1630

            
Bogdan Timofte authored 4 days ago
1631
my $db_handle;
Bogdan Timofte authored 4 days ago
1632
my $db_seeded = 0;
Bogdan Timofte authored 4 days ago
1633

            
1634
sub dbh {
1635
    return $db_handle if $db_handle;
1636
    ensure_parent_dir($opt{db});
1637
    $db_handle = DBI->connect(
1638
        "dbi:SQLite:dbname=$opt{db}",
1639
        '',
1640
        '',
1641
        {
1642
            RaiseError => 1,
1643
            PrintError => 0,
1644
            AutoCommit => 1,
1645
            sqlite_unicode => 1,
1646
        },
1647
    ) or die "Cannot open SQLite database $opt{db}\n";
1648
    $db_handle->do('PRAGMA journal_mode = WAL');
1649
    $db_handle->do('PRAGMA foreign_keys = ON');
Bogdan Timofte authored 4 days ago
1650
    create_database_schema($db_handle);
1651
    seed_database($db_handle) unless $db_seeded++;
1652
    return $db_handle;
1653
}
1654

            
1655
sub create_database_schema {
1656
    my ($dbh) = @_;
1657
    $dbh->do(<<'SQL');
1658
CREATE TABLE IF NOT EXISTS schema_meta (
1659
    key TEXT PRIMARY KEY,
1660
    value TEXT NOT NULL,
1661
    updated_at TEXT NOT NULL
1662
)
1663
SQL
1664
    $dbh->do(<<'SQL');
Bogdan Timofte authored 4 days ago
1665
CREATE TABLE IF NOT EXISTS documents (
1666
    name TEXT PRIMARY KEY,
1667
    content TEXT NOT NULL,
1668
    updated_at TEXT NOT NULL
1669
)
1670
SQL
Bogdan Timofte authored 4 days ago
1671
    $dbh->do(
1672
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
1673
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
1674
        undef, 'schema_version', '2', iso_now()
1675
    );
1676
    $dbh->do(<<'SQL');
1677
CREATE TABLE IF NOT EXISTS hosts (
1678
    fqdn TEXT PRIMARY KEY,
1679
    legacy_id TEXT NOT NULL UNIQUE,
1680
    status TEXT NOT NULL DEFAULT 'active',
1681
    hosts_ip TEXT NOT NULL DEFAULT '',
1682
    dns_ip TEXT NOT NULL DEFAULT '',
1683
    monitoring TEXT NOT NULL DEFAULT 'pending',
1684
    notes TEXT NOT NULL DEFAULT '',
1685
    created_at TEXT NOT NULL,
1686
    updated_at TEXT NOT NULL
1687
)
1688
SQL
1689
    $dbh->do(<<'SQL');
1690
CREATE TABLE IF NOT EXISTS host_aliases (
1691
    alias_name TEXT NOT NULL,
1692
    host_fqdn TEXT NOT NULL,
1693
    alias_kind TEXT NOT NULL DEFAULT 'declared',
1694
    status TEXT NOT NULL DEFAULT 'active',
1695
    is_dns_published INTEGER NOT NULL DEFAULT 1,
1696
    created_at TEXT NOT NULL,
1697
    retired_at TEXT,
1698
    notes TEXT NOT NULL DEFAULT '',
1699
    PRIMARY KEY (alias_name, host_fqdn),
1700
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1701
)
1702
SQL
1703
    $dbh->do(<<'SQL');
1704
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_aliases_active_name
1705
ON host_aliases(alias_name)
1706
WHERE status = 'active'
1707
SQL
1708
    $dbh->do(<<'SQL');
1709
CREATE INDEX IF NOT EXISTS idx_host_aliases_host_status
1710
ON host_aliases(host_fqdn, status)
1711
SQL
1712
    $dbh->do(<<'SQL');
1713
CREATE TABLE IF NOT EXISTS host_roles (
1714
    host_fqdn TEXT NOT NULL,
1715
    role TEXT NOT NULL,
1716
    status TEXT NOT NULL DEFAULT 'active',
1717
    created_at TEXT NOT NULL,
1718
    retired_at TEXT,
1719
    PRIMARY KEY (host_fqdn, role),
1720
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1721
)
1722
SQL
1723
    $dbh->do(<<'SQL');
1724
CREATE TABLE IF NOT EXISTS host_sources (
1725
    host_fqdn TEXT NOT NULL,
1726
    source TEXT NOT NULL,
1727
    status TEXT NOT NULL DEFAULT 'active',
1728
    created_at TEXT NOT NULL,
1729
    retired_at TEXT,
1730
    PRIMARY KEY (host_fqdn, source),
1731
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1732
)
1733
SQL
1734
    $dbh->do(<<'SQL');
1735
CREATE TABLE IF NOT EXISTS host_flags (
1736
    host_fqdn TEXT NOT NULL,
1737
    flag TEXT NOT NULL,
1738
    value TEXT NOT NULL DEFAULT '1',
1739
    created_at TEXT NOT NULL,
1740
    updated_at TEXT NOT NULL,
1741
    PRIMARY KEY (host_fqdn, flag),
1742
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1743
)
1744
SQL
1745
    $dbh->do(<<'SQL');
1746
CREATE TABLE IF NOT EXISTS host_ssh (
1747
    host_fqdn TEXT NOT NULL,
1748
    profile_name TEXT NOT NULL DEFAULT 'default',
1749
    username TEXT NOT NULL DEFAULT '',
1750
    port INTEGER NOT NULL DEFAULT 22,
1751
    identity_file TEXT NOT NULL DEFAULT '',
1752
    address TEXT NOT NULL DEFAULT '',
1753
    local_forward_host TEXT NOT NULL DEFAULT '',
1754
    local_forward_port INTEGER,
1755
    remote_forward_host TEXT NOT NULL DEFAULT '',
1756
    remote_forward_port INTEGER,
1757
    notes TEXT NOT NULL DEFAULT '',
1758
    created_at TEXT NOT NULL,
1759
    updated_at TEXT NOT NULL,
1760
    PRIMARY KEY (host_fqdn, profile_name),
1761
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT
1762
)
1763
SQL
1764
    $dbh->do(<<'SQL');
1765
CREATE TABLE IF NOT EXISTS certificates (
1766
    certificate_id TEXT PRIMARY KEY,
1767
    host_fqdn TEXT,
1768
    common_name TEXT NOT NULL DEFAULT '',
1769
    subject TEXT NOT NULL DEFAULT '',
1770
    issuer TEXT NOT NULL DEFAULT '',
1771
    serial TEXT UNIQUE,
1772
    status TEXT NOT NULL DEFAULT 'issued',
1773
    not_before TEXT NOT NULL DEFAULT '',
1774
    not_after TEXT NOT NULL DEFAULT '',
1775
    fingerprint_sha256 TEXT UNIQUE,
1776
    cert_path TEXT NOT NULL DEFAULT '',
1777
    csr_path TEXT NOT NULL DEFAULT '',
1778
    created_at TEXT NOT NULL,
1779
    updated_at TEXT NOT NULL,
1780
    notes TEXT NOT NULL DEFAULT '',
1781
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1782
)
1783
SQL
1784
    $dbh->do(<<'SQL');
1785
CREATE TABLE IF NOT EXISTS certificate_dns_names (
1786
    certificate_id TEXT NOT NULL,
1787
    dns_name TEXT NOT NULL,
1788
    PRIMARY KEY (certificate_id, dns_name),
1789
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE CASCADE
1790
)
1791
SQL
1792
    $dbh->do(<<'SQL');
1793
CREATE INDEX IF NOT EXISTS idx_certificate_dns_names_dns_name
1794
ON certificate_dns_names(dns_name)
1795
SQL
1796
    $dbh->do(<<'SQL');
1797
CREATE TABLE IF NOT EXISTS vhosts (
1798
    vhost_fqdn TEXT PRIMARY KEY,
1799
    host_fqdn TEXT NOT NULL,
1800
    status TEXT NOT NULL DEFAULT 'active',
1801
    service_name TEXT NOT NULL DEFAULT '',
1802
    upstream_url TEXT NOT NULL DEFAULT '',
1803
    tls_mode TEXT NOT NULL DEFAULT 'local-ca',
1804
    certificate_id TEXT,
1805
    notes TEXT NOT NULL DEFAULT '',
1806
    created_at TEXT NOT NULL,
1807
    updated_at TEXT NOT NULL,
1808
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE RESTRICT,
1809
    FOREIGN KEY (certificate_id) REFERENCES certificates(certificate_id) ON UPDATE CASCADE ON DELETE SET NULL
1810
)
1811
SQL
1812
    $dbh->do(<<'SQL');
1813
CREATE INDEX IF NOT EXISTS idx_vhosts_host_status
1814
ON vhosts(host_fqdn, status)
1815
SQL
1816
    $dbh->do(<<'SQL');
1817
CREATE TABLE IF NOT EXISTS data_workers (
1818
    worker_id TEXT PRIMARY KEY,
1819
    worker_type TEXT NOT NULL,
1820
    name TEXT NOT NULL DEFAULT '',
1821
    status TEXT NOT NULL DEFAULT 'active',
1822
    source TEXT NOT NULL DEFAULT '',
1823
    last_run_at TEXT,
1824
    notes TEXT NOT NULL DEFAULT '',
1825
    created_at TEXT NOT NULL,
1826
    updated_at TEXT NOT NULL
1827
)
1828
SQL
1829
    $dbh->do(<<'SQL');
1830
CREATE INDEX IF NOT EXISTS idx_data_workers_type_status
1831
ON data_workers(worker_type, status)
1832
SQL
1833
    $dbh->do(<<'SQL');
1834
CREATE TABLE IF NOT EXISTS dhcp_leases (
1835
    lease_key TEXT PRIMARY KEY,
1836
    worker_id TEXT NOT NULL,
1837
    host_fqdn TEXT,
1838
    observed_name TEXT NOT NULL DEFAULT '',
1839
    ip_address TEXT NOT NULL,
1840
    mac_address TEXT NOT NULL DEFAULT '',
1841
    lease_state TEXT NOT NULL DEFAULT '',
1842
    first_seen TEXT NOT NULL,
1843
    last_seen TEXT NOT NULL,
1844
    raw TEXT NOT NULL DEFAULT '',
1845
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1846
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1847
)
1848
SQL
1849
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_ip ON dhcp_leases(ip_address)');
1850
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_mac ON dhcp_leases(mac_address)');
1851
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_dhcp_leases_worker_last_seen ON dhcp_leases(worker_id, last_seen)');
1852
    $dbh->do(<<'SQL');
1853
CREATE TABLE IF NOT EXISTS mdns_observations (
1854
    observation_key TEXT PRIMARY KEY,
1855
    worker_id TEXT NOT NULL,
1856
    host_fqdn TEXT,
1857
    observed_name TEXT NOT NULL,
1858
    ip_address TEXT NOT NULL,
1859
    rr_type TEXT NOT NULL DEFAULT 'A',
1860
    ttl INTEGER NOT NULL DEFAULT 0,
1861
    first_seen TEXT NOT NULL,
1862
    last_seen TEXT NOT NULL,
1863
    seen_count INTEGER NOT NULL DEFAULT 1,
1864
    last_peer TEXT NOT NULL DEFAULT '',
1865
    raw TEXT NOT NULL DEFAULT '',
1866
    FOREIGN KEY (worker_id) REFERENCES data_workers(worker_id) ON UPDATE CASCADE ON DELETE RESTRICT,
1867
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1868
)
1869
SQL
1870
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_name ON mdns_observations(observed_name)');
1871
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_ip ON mdns_observations(ip_address)');
1872
    $dbh->do('CREATE INDEX IF NOT EXISTS idx_mdns_observations_worker_last_seen ON mdns_observations(worker_id, last_seen)');
1873
    $dbh->do(<<'SQL');
1874
CREATE TABLE IF NOT EXISTS work_orders (
1875
    id TEXT PRIMARY KEY,
1876
    status TEXT NOT NULL DEFAULT 'pending',
1877
    title TEXT NOT NULL DEFAULT '',
1878
    reason TEXT NOT NULL DEFAULT '',
1879
    created_at TEXT NOT NULL,
1880
    confirmed_at TEXT NOT NULL DEFAULT '',
1881
    result TEXT NOT NULL DEFAULT '',
1882
    updated_at TEXT NOT NULL
1883
)
1884
SQL
1885
    $dbh->do(<<'SQL');
1886
CREATE TABLE IF NOT EXISTS work_order_checklist (
1887
    work_order_id TEXT NOT NULL,
1888
    item_id TEXT NOT NULL,
1889
    text TEXT NOT NULL DEFAULT '',
1890
    status TEXT NOT NULL DEFAULT 'pending',
1891
    owner TEXT NOT NULL DEFAULT '',
1892
    notes TEXT NOT NULL DEFAULT '',
1893
    updated_at TEXT NOT NULL DEFAULT '',
1894
    PRIMARY KEY (work_order_id, item_id),
1895
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE
1896
)
1897
SQL
1898
    $dbh->do(<<'SQL');
1899
CREATE TABLE IF NOT EXISTS work_order_actions (
1900
    work_order_id TEXT NOT NULL,
1901
    position INTEGER NOT NULL,
1902
    type TEXT NOT NULL,
1903
    host_fqdn TEXT,
1904
    host_legacy_id TEXT NOT NULL DEFAULT '',
1905
    name TEXT NOT NULL DEFAULT '',
1906
    payload TEXT NOT NULL DEFAULT '',
1907
    PRIMARY KEY (work_order_id, position),
1908
    FOREIGN KEY (work_order_id) REFERENCES work_orders(id) ON UPDATE CASCADE ON DELETE CASCADE,
1909
    FOREIGN KEY (host_fqdn) REFERENCES hosts(fqdn) ON UPDATE CASCADE ON DELETE SET NULL
1910
)
1911
SQL
Bogdan Timofte authored 4 days ago
1912
}
1913

            
Bogdan Timofte authored 4 days ago
1914
sub seed_database {
1915
    my ($dbh) = @_;
1916
    seed_default_workers($dbh);
1917

            
1918
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM hosts')) {
1919
        my $registry = parse_hosts_yaml(legacy_document_text($dbh, 'hosts_yaml', $opt{data}, default_hosts_yaml()));
1920
        normalize_registry_policy($registry);
1921
        with_transaction($dbh, sub {
1922
            import_registry_to_db($dbh, $registry, 0);
1923
        });
1924
    }
1925

            
1926
    if (!db_scalar($dbh, 'SELECT COUNT(*) FROM work_orders')) {
1927
        my $orders = parse_work_orders_yaml(legacy_document_text($dbh, 'work_orders_yaml', $opt{work_orders}, default_work_orders_yaml()));
1928
        with_transaction($dbh, sub {
1929
            import_work_orders_to_db($dbh, $orders);
1930
        });
1931
    }
1932

            
1933
    seed_mdns_observations_from_yaml($dbh);
1934
}
1935

            
1936
sub with_transaction {
1937
    my ($dbh, $code) = @_;
1938
    return $code->() unless $dbh->{AutoCommit};
1939
    $dbh->begin_work;
1940
    my $ok = eval {
1941
        $code->();
1942
        1;
1943
    };
1944
    if (!$ok) {
1945
        my $err = $@ || 'transaction failed';
1946
        eval { $dbh->rollback };
1947
        die $err;
1948
    }
1949
    $dbh->commit;
1950
}
1951

            
1952
sub db_scalar {
1953
    my ($dbh, $sql, @bind) = @_;
1954
    my ($value) = $dbh->selectrow_array($sql, undef, @bind);
1955
    return $value || 0;
1956
}
1957

            
1958
sub legacy_document_text {
1959
    my ($dbh, $name, $seed_path, $default_text) = @_;
Bogdan Timofte authored 4 days ago
1960
    my $row = $dbh->selectrow_hashref('SELECT content FROM documents WHERE name = ?', undef, $name);
Bogdan Timofte authored 4 days ago
1961
    return $row->{content} if $row && defined $row->{content};
1962
    return read_file($seed_path) if -f $seed_path;
1963
    return $default_text;
1964
}
1965

            
1966
sub load_registry_from_db {
1967
    my $dbh = dbh();
1968
    my $registry = {
1969
        version => 1,
1970
        updated_at => db_scalar($dbh, 'SELECT value FROM schema_meta WHERE key = ?', 'registry_updated_at') || '',
1971
        policy => {},
1972
        hosts => [],
1973
    };
Bogdan Timofte authored 4 days ago
1974

            
Bogdan Timofte authored 4 days ago
1975
    my $sth = $dbh->prepare('SELECT * FROM hosts ORDER BY legacy_id');
1976
    $sth->execute;
1977
    while (my $row = $sth->fetchrow_hashref) {
1978
        my $fqdn = $row->{fqdn};
1979
        push @{ $registry->{hosts} }, {
1980
            id => $row->{legacy_id},
Bogdan Timofte authored 4 days ago
1981
            fqdn => $fqdn,
Bogdan Timofte authored 4 days ago
1982
            status => $row->{status},
Bogdan Timofte authored 4 days ago
1983
            ip => canonical_ip($row),
1984
            aliases => [ active_aliases_for_host($dbh, $fqdn) ],
1985
            vhosts => [ active_vhosts_for_host($dbh, $fqdn) ],
Bogdan Timofte authored 4 days ago
1986
            roles => [ active_values_for_host($dbh, 'host_roles', 'role', $fqdn) ],
1987
            sources => [ active_values_for_host($dbh, 'host_sources', 'source', $fqdn) ],
1988
            monitoring => $row->{monitoring},
1989
            notes => $row->{notes},
1990
        };
1991
    }
1992

            
1993
    return $registry;
Bogdan Timofte authored 4 days ago
1994
}
1995

            
Bogdan Timofte authored 4 days ago
1996
sub save_registry_to_db {
1997
    my ($registry) = @_;
Bogdan Timofte authored 4 days ago
1998
    my $dbh = dbh();
Bogdan Timofte authored 4 days ago
1999
    with_transaction($dbh, sub {
2000
        import_registry_to_db($dbh, $registry, 1);
2001
        set_schema_meta($dbh, 'registry_updated_at', $registry->{updated_at} || iso_now());
2002
    });
2003
}
2004

            
2005
sub import_registry_to_db {
2006
    my ($dbh, $registry, $retire_missing) = @_;
2007
    my %seen;
2008
    for my $host (@{ $registry->{hosts} || [] }) {
2009
        my $fqdn = upsert_host_to_db($dbh, $host);
2010
        $seen{$fqdn} = 1 if $fqdn;
2011
    }
2012

            
2013
    return unless $retire_missing;
2014
    my $sth = $dbh->prepare('SELECT fqdn FROM hosts WHERE status <> ?');
2015
    $sth->execute('retired');
2016
    while (my ($fqdn) = $sth->fetchrow_array) {
2017
        next if $seen{$fqdn};
2018
        retire_host_in_db($dbh, $fqdn);
2019
    }
2020
}
2021

            
2022
sub upsert_host_to_db {
2023
    my ($dbh, $host) = @_;
2024
    my $now = iso_now();
2025
    my $fqdn = canonical_host_fqdn($host);
2026
    return '' unless $fqdn;
2027
    my $legacy_id = clean_id($host->{id} || legacy_id_from_fqdn($fqdn));
2028
    my $status = clean_scalar($host->{status} || 'active');
Bogdan Timofte authored 4 days ago
2029
    my $ip = canonical_ip($host);
Bogdan Timofte authored 4 days ago
2030
    my $monitoring = clean_scalar($host->{monitoring} || 'pending');
2031
    my $notes = clean_scalar($host->{notes} || '');
2032

            
Bogdan Timofte authored 4 days ago
2033
    $dbh->do(
Bogdan Timofte authored 4 days ago
2034
        'INSERT INTO hosts (fqdn, legacy_id, status, hosts_ip, dns_ip, monitoring, notes, created_at, updated_at) '
2035
        . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) '
2036
        . 'ON CONFLICT(fqdn) DO UPDATE SET legacy_id = excluded.legacy_id, status = excluded.status, '
2037
        . 'hosts_ip = excluded.hosts_ip, dns_ip = excluded.dns_ip, monitoring = excluded.monitoring, '
2038
        . 'notes = excluded.notes, updated_at = excluded.updated_at',
Bogdan Timofte authored 4 days ago
2039
        undef,
Bogdan Timofte authored 4 days ago
2040
        $fqdn, $legacy_id, $status, $ip, $ip, $monitoring, $notes, $now, $now,
Bogdan Timofte authored 4 days ago
2041
    );
2042

            
2043
    sync_host_values($dbh, 'host_roles', 'role', $fqdn, [ clean_list($host->{roles}) ]);
2044
    sync_host_values($dbh, 'host_sources', 'source', $fqdn, [ clean_list($host->{sources}) ]);
Bogdan Timofte authored 4 days ago
2045
    sync_host_aliases_and_vhosts($dbh, $fqdn, [ declared_alias_names($host) ], [ declared_vhost_names($host) ]);
Bogdan Timofte authored 4 days ago
2046
    return $fqdn;
2047
}
2048

            
2049
sub sync_host_values {
2050
    my ($dbh, $table, $column, $fqdn, $values) = @_;
2051
    my $now = iso_now();
2052
    my %active = map { $_ => 1 } @$values;
2053
    for my $value (@$values) {
2054
        $dbh->do(
2055
            "INSERT INTO $table (host_fqdn, $column, status, created_at, retired_at) VALUES (?, ?, 'active', ?, '') "
2056
            . "ON CONFLICT(host_fqdn, $column) DO UPDATE SET status = 'active', retired_at = ''",
2057
            undef,
2058
            $fqdn, $value, $now,
2059
        );
2060
    }
2061

            
2062
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2063
    $sth->execute($fqdn);
2064
    while (my ($value) = $sth->fetchrow_array) {
2065
        next if $active{$value};
2066
        $dbh->do("UPDATE $table SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND $column = ?", undef, $now, $fqdn, $value);
2067
    }
2068
}
2069

            
Bogdan Timofte authored 4 days ago
2070
sub sync_host_aliases_and_vhosts {
2071
    my ($dbh, $fqdn, $aliases_in, $vhosts_in) = @_;
Bogdan Timofte authored 4 days ago
2072
    my $now = iso_now();
2073
    my (%aliases, %vhosts);
2074
    if (my $short = short_alias_for_fqdn($fqdn)) {
2075
        $aliases{$short} = 1;
2076
        upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2077
    }
Bogdan Timofte authored 4 days ago
2078
    for my $name (@$aliases_in) {
Bogdan Timofte authored 4 days ago
2079
        $name = normalize_dns_name($name);
2080
        next unless length $name;
2081
        next if $name eq $fqdn;
Bogdan Timofte authored 4 days ago
2082
        $aliases{$name} = 1;
2083
        upsert_alias_to_db($dbh, $fqdn, $name, 'declared', $now);
2084
        if (my $short = short_alias_for_fqdn($name)) {
2085
            $aliases{$short} = 1;
2086
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived', $now);
2087
        }
2088
    }
2089
    for my $name (@$vhosts_in) {
2090
        $name = normalize_dns_name($name);
2091
        next unless length $name;
2092
        $vhosts{$name} = 1;
2093
        upsert_vhost_to_db($dbh, $fqdn, $name, $now);
2094
        if (my $short = short_alias_for_fqdn($name)) {
2095
            $aliases{$short} = 1;
2096
            upsert_alias_to_db($dbh, $fqdn, $short, 'derived-vhost', $now);
Bogdan Timofte authored 4 days ago
2097
        }
2098
    }
2099

            
2100
    retire_missing_names($dbh, 'host_aliases', 'alias_name', $fqdn, \%aliases, $now);
2101
    retire_missing_names($dbh, 'vhosts', 'vhost_fqdn', $fqdn, \%vhosts, $now);
2102
}
2103

            
2104
sub upsert_alias_to_db {
2105
    my ($dbh, $fqdn, $alias, $kind, $now) = @_;
Bogdan Timofte authored 4 days ago
2106
    my ($existing_fqdn) = $dbh->selectrow_array(
2107
        "SELECT host_fqdn FROM host_aliases WHERE alias_name = ? AND status = 'active'",
2108
        undef,
2109
        $alias,
2110
    );
2111
    if ($existing_fqdn && $existing_fqdn ne $fqdn) {
2112
        if ($kind eq 'derived-vhost') {
2113
            $dbh->do(
2114
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE alias_name = ? AND host_fqdn = ? AND status = 'active'",
2115
                undef,
2116
                $now, $alias, $existing_fqdn,
2117
            );
2118
        } else {
2119
            die "alias_conflict: $alias is already active on $existing_fqdn\n";
2120
        }
2121
    }
Bogdan Timofte authored 4 days ago
2122
    $dbh->do(
2123
        'INSERT INTO host_aliases (alias_name, host_fqdn, alias_kind, status, is_dns_published, created_at, retired_at, notes) '
2124
        . "VALUES (?, ?, ?, 'active', 1, ?, '', '') "
2125
        . "ON CONFLICT(alias_name, host_fqdn) DO UPDATE SET alias_kind = excluded.alias_kind, status = 'active', is_dns_published = 1, retired_at = ''",
2126
        undef,
2127
        $alias, $fqdn, $kind, $now,
2128
    );
2129
}
2130

            
2131
sub upsert_vhost_to_db {
2132
    my ($dbh, $fqdn, $vhost, $now) = @_;
2133
    my $service = vhost_service_name($vhost);
2134
    $dbh->do(
2135
        'INSERT INTO vhosts (vhost_fqdn, host_fqdn, status, service_name, upstream_url, tls_mode, certificate_id, notes, created_at, updated_at) '
2136
        . "VALUES (?, ?, 'active', ?, '', 'local-ca', NULL, '', ?, ?) "
2137
        . "ON CONFLICT(vhost_fqdn) DO UPDATE SET host_fqdn = excluded.host_fqdn, status = 'active', "
2138
        . 'service_name = excluded.service_name, updated_at = excluded.updated_at',
2139
        undef,
2140
        $vhost, $fqdn, $service, $now, $now,
2141
    );
2142
}
2143

            
2144
sub retire_missing_names {
2145
    my ($dbh, $table, $name_column, $fqdn, $active, $now) = @_;
2146
    my $sth = $dbh->prepare("SELECT $name_column FROM $table WHERE host_fqdn = ? AND status = 'active'");
2147
    $sth->execute($fqdn);
2148
    while (my ($name) = $sth->fetchrow_array) {
2149
        next if $active->{$name};
2150
        if ($table eq 'host_aliases') {
2151
            $dbh->do(
2152
                "UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND alias_name = ?",
2153
                undef, $now, $fqdn, $name,
2154
            );
2155
        } else {
2156
            $dbh->do(
2157
                "UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND vhost_fqdn = ?",
2158
                undef, $now, $fqdn, $name,
2159
            );
2160
        }
2161
    }
2162
}
2163

            
2164
sub retire_host_in_db {
2165
    my ($dbh, $fqdn) = @_;
2166
    my $now = iso_now();
2167
    $dbh->do("UPDATE hosts SET status = 'retired', updated_at = ? WHERE fqdn = ?", undef, $now, $fqdn);
2168
    $dbh->do("UPDATE host_aliases SET status = 'retired', is_dns_published = 0, retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2169
    $dbh->do("UPDATE vhosts SET status = 'retired', updated_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2170
    $dbh->do("UPDATE host_roles SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2171
    $dbh->do("UPDATE host_sources SET status = 'retired', retired_at = ? WHERE host_fqdn = ? AND status = 'active'", undef, $now, $fqdn);
2172
}
2173

            
Bogdan Timofte authored 4 days ago
2174
sub active_aliases_for_host {
Bogdan Timofte authored 4 days ago
2175
    my ($dbh, $fqdn) = @_;
Bogdan Timofte authored 4 days ago
2176
    my @names;
Bogdan Timofte authored 4 days ago
2177
    my $aliases = $dbh->prepare("SELECT alias_name FROM host_aliases WHERE host_fqdn = ? AND status = 'active' AND is_dns_published = 1 AND alias_kind NOT LIKE 'derived%' ORDER BY alias_name");
2178
    $aliases->execute($fqdn);
2179
    while (my ($name) = $aliases->fetchrow_array) {
2180
        push @names, $name;
2181
    }
Bogdan Timofte authored 4 days ago
2182
    return unique_preserve(@names);
2183
}
2184

            
2185
sub active_vhosts_for_host {
2186
    my ($dbh, $fqdn) = @_;
2187
    my @names;
Bogdan Timofte authored 4 days ago
2188
    my $vhosts = $dbh->prepare("SELECT vhost_fqdn FROM vhosts WHERE host_fqdn = ? AND status = 'active' ORDER BY vhost_fqdn");
2189
    $vhosts->execute($fqdn);
2190
    while (my ($name) = $vhosts->fetchrow_array) {
2191
        push @names, $name;
2192
    }
2193
    return unique_preserve(@names);
2194
}
2195

            
2196
sub active_values_for_host {
2197
    my ($dbh, $table, $column, $fqdn) = @_;
2198
    my @values;
2199
    my $sth = $dbh->prepare("SELECT $column FROM $table WHERE host_fqdn = ? AND status = 'active' ORDER BY $column");
2200
    $sth->execute($fqdn);
2201
    while (my ($value) = $sth->fetchrow_array) {
2202
        push @values, $value;
2203
    }
2204
    return @values;
2205
}
2206

            
2207
sub load_work_orders_from_db {
2208
    my $dbh = dbh();
2209
    my $orders = { version => 1, work_orders => [] };
2210
    my $sth = $dbh->prepare('SELECT * FROM work_orders ORDER BY id');
2211
    $sth->execute;
2212
    while (my $row = $sth->fetchrow_hashref) {
2213
        my $wo = {
2214
            id => $row->{id},
2215
            status => $row->{status},
2216
            title => $row->{title},
2217
            reason => $row->{reason},
2218
            created_at => $row->{created_at},
2219
            checklist => [],
2220
            actions => [],
2221
        };
2222
        $wo->{confirmed_at} = $row->{confirmed_at} if length($row->{confirmed_at} || '');
2223
        $wo->{result} = $row->{result} if length($row->{result} || '');
2224

            
2225
        my $items = $dbh->prepare('SELECT * FROM work_order_checklist WHERE work_order_id = ? ORDER BY item_id');
2226
        $items->execute($row->{id});
2227
        while (my $item = $items->fetchrow_hashref) {
2228
            my %copy = (
2229
                id => $item->{item_id},
2230
                text => $item->{text},
2231
                status => $item->{status},
2232
            );
2233
            for my $key (qw(owner notes updated_at)) {
2234
                $copy{$key} = $item->{$key} if length($item->{$key} || '');
2235
            }
2236
            push @{ $wo->{checklist} }, \%copy;
2237
        }
2238

            
2239
        my $actions = $dbh->prepare('SELECT * FROM work_order_actions WHERE work_order_id = ? ORDER BY position');
2240
        $actions->execute($row->{id});
2241
        while (my $action = $actions->fetchrow_hashref) {
2242
            my %copy = ( type => $action->{type} );
2243
            $copy{host_id} = $action->{host_legacy_id} if length($action->{host_legacy_id} || '');
2244
            $copy{name} = $action->{name} if length($action->{name} || '');
2245
            push @{ $wo->{actions} }, \%copy;
2246
        }
2247

            
2248
        push @{ $orders->{work_orders} }, $wo;
2249
    }
2250
    return $orders;
2251
}
2252

            
2253
sub save_work_orders_to_db {
2254
    my ($orders) = @_;
2255
    my $dbh = dbh();
2256
    with_transaction($dbh, sub {
2257
        import_work_orders_to_db($dbh, $orders);
2258
    });
2259
}
2260

            
2261
sub import_work_orders_to_db {
2262
    my ($dbh, $orders) = @_;
2263
    my $now = iso_now();
2264
    my %seen;
2265
    for my $wo (@{ $orders->{work_orders} || [] }) {
2266
        my $id = clean_scalar($wo->{id} || '');
2267
        next unless $id;
2268
        $seen{$id} = 1;
2269
        $dbh->do(
2270
            'INSERT INTO work_orders (id, status, title, reason, created_at, confirmed_at, result, updated_at) '
2271
            . 'VALUES (?, ?, ?, ?, ?, ?, ?, ?) '
2272
            . 'ON CONFLICT(id) DO UPDATE SET status = excluded.status, title = excluded.title, reason = excluded.reason, '
2273
            . 'created_at = excluded.created_at, confirmed_at = excluded.confirmed_at, result = excluded.result, updated_at = excluded.updated_at',
2274
            undef,
2275
            $id,
2276
            clean_scalar($wo->{status} || 'pending'),
2277
            clean_scalar($wo->{title} || ''),
2278
            clean_scalar($wo->{reason} || ''),
2279
            clean_scalar($wo->{created_at} || $now),
2280
            clean_scalar($wo->{confirmed_at} || ''),
2281
            clean_scalar($wo->{result} || ''),
2282
            $now,
2283
        );
2284
        $dbh->do('DELETE FROM work_order_checklist WHERE work_order_id = ?', undef, $id);
2285
        for my $item (@{ $wo->{checklist} || [] }) {
2286
            $dbh->do(
2287
                'INSERT INTO work_order_checklist (work_order_id, item_id, text, status, owner, notes, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
2288
                undef,
2289
                $id,
2290
                clean_scalar($item->{id} || ''),
2291
                clean_scalar($item->{text} || ''),
2292
                clean_scalar($item->{status} || 'pending'),
2293
                clean_scalar($item->{owner} || ''),
2294
                clean_scalar($item->{notes} || ''),
2295
                clean_scalar($item->{updated_at} || ''),
2296
            );
2297
        }
2298
        $dbh->do('DELETE FROM work_order_actions WHERE work_order_id = ?', undef, $id);
2299
        my $position = 0;
2300
        for my $action (@{ $wo->{actions} || [] }) {
2301
            my $legacy_id = clean_id($action->{host_id} || '');
2302
            my $host_fqdn = fqdn_for_legacy_id($dbh, $legacy_id);
2303
            $dbh->do(
2304
                'INSERT INTO work_order_actions (work_order_id, position, type, host_fqdn, host_legacy_id, name, payload) VALUES (?, ?, ?, ?, ?, ?, ?)',
2305
                undef,
2306
                $id,
2307
                $position++,
2308
                clean_scalar($action->{type} || ''),
2309
                $host_fqdn || undef,
2310
                $legacy_id,
2311
                normalize_dns_name($action->{name} || ''),
2312
                '',
2313
            );
2314
        }
2315
    }
2316
}
2317

            
2318
sub seed_default_workers {
2319
    my ($dbh) = @_;
2320
    my $now = iso_now();
2321
    my @workers = (
2322
        [ 'dhcp-router', 'dhcp', 'Router DHCP leases', 'admin@192.168.2.1', 'DHCP lease/reservation collector source.' ],
2323
        [ 'mdns-listener', 'mdns', 'mDNS listener', 'var/mdns-observations.yaml', 'mDNS observation collector source.' ],
2324
    );
2325
    for my $worker (@workers) {
2326
        $dbh->do(
2327
            'INSERT INTO data_workers (worker_id, worker_type, name, status, source, last_run_at, notes, created_at, updated_at) '
2328
            . "VALUES (?, ?, ?, 'active', ?, NULL, ?, ?, ?) "
2329
            . 'ON CONFLICT(worker_id) DO UPDATE SET worker_type = excluded.worker_type, name = excluded.name, '
2330
            . 'status = excluded.status, source = excluded.source, notes = excluded.notes, updated_at = excluded.updated_at',
2331
            undef,
2332
            @$worker,
2333
            $now,
2334
            $now,
2335
        );
2336
    }
2337
}
2338

            
2339
sub seed_mdns_observations_from_yaml {
2340
    my ($dbh) = @_;
2341
    return if db_scalar($dbh, 'SELECT COUNT(*) FROM mdns_observations');
2342
    my $path = "$project_dir/var/mdns-observations.yaml";
2343
    return unless -f $path;
2344
    my $db = parse_mdns_observations_yaml(read_file($path));
2345
    with_transaction($dbh, sub {
2346
        for my $observation (@{ $db->{observations} || [] }) {
2347
            $dbh->do(
2348
                'INSERT INTO mdns_observations (observation_key, worker_id, host_fqdn, observed_name, ip_address, rr_type, ttl, first_seen, last_seen, seen_count, last_peer, raw) '
2349
                . "VALUES (?, 'mdns-listener', NULL, ?, ?, 'A', ?, ?, ?, ?, ?, '') "
2350
                . 'ON CONFLICT(observation_key) DO UPDATE SET observed_name = excluded.observed_name, ip_address = excluded.ip_address, '
2351
                . 'ttl = excluded.ttl, last_seen = excluded.last_seen, seen_count = excluded.seen_count, last_peer = excluded.last_peer',
2352
                undef,
2353
                clean_scalar($observation->{key} || "$observation->{name}|$observation->{ip}"),
2354
                clean_scalar($observation->{name} || ''),
2355
                clean_scalar($observation->{ip} || ''),
2356
                int($observation->{ttl} || 0),
2357
                clean_scalar($observation->{first_seen} || iso_now()),
2358
                clean_scalar($observation->{last_seen} || iso_now()),
2359
                int($observation->{seen_count} || 1),
2360
                clean_scalar($observation->{last_peer} || ''),
2361
            );
2362
        }
2363
    });
2364
}
2365

            
2366
sub parse_mdns_observations_yaml {
2367
    my ($text) = @_;
2368
    my %db = ( observations => [] );
2369
    my ($section, $current);
2370
    for my $line (split /\n/, $text || '') {
2371
        next if $line =~ /^\s*$/ || $line =~ /^\s*#/;
2372
        if ($line =~ /^observations:\s*$/) {
2373
            $section = 'observations';
2374
        } elsif (($section || '') eq 'observations' && $line =~ /^  - key:\s*(.+)$/) {
2375
            $current = { key => yaml_unquote($1) };
2376
            push @{ $db{observations} }, $current;
2377
        } elsif ($current && $line =~ /^    ([A-Za-z0-9_]+):\s*(.*)$/) {
2378
            $current->{$1} = yaml_unquote($2);
2379
        }
2380
    }
2381
    return \%db;
2382
}
2383

            
2384
sub set_schema_meta {
2385
    my ($dbh, $key, $value) = @_;
2386
    $dbh->do(
2387
        'INSERT INTO schema_meta (key, value, updated_at) VALUES (?, ?, ?) '
2388
        . 'ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at',
2389
        undef,
2390
        $key,
2391
        defined $value ? $value : '',
Bogdan Timofte authored 4 days ago
2392
        iso_now(),
2393
    );
2394
}
2395

            
Bogdan Timofte authored 4 days ago
2396
sub fqdn_for_legacy_id {
2397
    my ($dbh, $legacy_id) = @_;
2398
    return '' unless length($legacy_id || '');
2399
    my ($fqdn) = $dbh->selectrow_array('SELECT fqdn FROM hosts WHERE legacy_id = ?', undef, $legacy_id);
2400
    return $fqdn || '';
2401
}
2402

            
2403
sub canonical_host_fqdn {
2404
    my ($host) = @_;
Bogdan Timofte authored 4 days ago
2405
    my $fqdn = normalize_dns_name($host->{fqdn} || '');
2406
    return $fqdn if length $fqdn;
2407
    my @names = declared_dns_names_legacy($host);
Bogdan Timofte authored 4 days ago
2408
    for my $name (@names) {
2409
        return $name if $name =~ /\.madagascar\.xdev\.ro\z/ && !name_is_vhost($name);
2410
    }
2411
    for my $name (@names) {
2412
        return $name if $name =~ /\./ && !name_is_vhost($name);
2413
    }
2414
    my $id = clean_id($host->{id} || '');
2415
    return $id ? "$id.madagascar.xdev.ro" : '';
2416
}
2417

            
2418
sub legacy_id_from_fqdn {
2419
    my ($fqdn) = @_;
2420
    $fqdn = normalize_dns_name($fqdn);
2421
    $fqdn =~ s/\.madagascar\.xdev\.ro\z//;
2422
    $fqdn =~ s/\..*\z//;
2423
    return clean_id($fqdn);
2424
}
2425

            
2426
sub normalize_dns_name {
2427
    my ($name) = @_;
2428
    $name = lc clean_scalar($name || '');
2429
    $name =~ s/\.\z//;
2430
    return $name;
2431
}
2432

            
2433
sub name_is_vhost {
2434
    my ($name) = @_;
2435
    $name = normalize_dns_name($name);
2436
    return $name =~ /\A(?:pmx|pbs|hosts)\./ ? 1 : 0;
2437
}
2438

            
2439
sub vhost_service_name {
2440
    my ($name) = @_;
2441
    $name = normalize_dns_name($name);
2442
    return $1 if $name =~ /\A([a-z0-9-]+)\./;
2443
    return '';
2444
}
2445

            
2446
sub short_alias_for_fqdn {
2447
    my ($name) = @_;
2448
    $name = normalize_dns_name($name);
2449
    return $1 if $name =~ /\A(.+)\.madagascar\.xdev\.ro\z/;
2450
    return '';
2451
}
2452

            
Bogdan Timofte authored 4 days ago
2453
sub normalize_registry_policy {
2454
    my ($registry) = @_;
2455
    $registry->{policy} ||= {};
Bogdan Timofte authored 4 days ago
2456
    $registry->{policy}{storage_authority} = 'sqlite-relational';
Bogdan Timofte authored 4 days ago
2457
    $registry->{policy}{runtime_database} = $opt{db};
2458
}
2459

            
2460
sub default_hosts_yaml {
2461
    return <<'YAML';
2462
version: 1
2463
updated_at: ""
2464
policy:
Bogdan Timofte authored 4 days ago
2465
  storage_authority: "sqlite-relational"
Bogdan Timofte authored 4 days ago
2466
hosts:
2467
YAML
2468
}
2469

            
2470
sub default_work_orders_yaml {
2471
    return <<'YAML';
2472
version: 1
2473
work_orders:
2474
YAML
2475
}
2476

            
2477
sub ensure_parent_dir {
2478
    my ($path) = @_;
2479
    my $dir = dirname($path);
2480
    make_path($dir) unless -d $dir;
2481
}
2482

            
Xdev Host Manager authored a week ago
2483
sub url_decode {
2484
    my ($value) = @_;
2485
    $value = '' unless defined $value;
2486
    $value =~ tr/+/ /;
2487
    $value =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
2488
    return $value;
2489
}
2490

            
2491
sub random_hex {
2492
    my ($bytes) = @_;
2493
    if (open my $fh, '<:raw', '/dev/urandom') {
2494
        read($fh, my $raw, $bytes);
2495
        close $fh;
2496
        return unpack('H*', $raw);
2497
    }
2498
    return sha256_hex(rand() . time() . $$);
2499
}
2500

            
2501
sub iso_now {
2502
    return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
2503
}
2504

            
Bogdan Timofte authored 6 days ago
2505
sub build_info {
2506
    my %info = (
2507
        revision => '',
2508
        branch => '',
2509
        built_at => '',
2510
        deployed_at => '',
2511
        dirty => '',
2512
    );
2513

            
2514
    if ($ENV{HOST_MANAGER_BUILD}) {
2515
        $info{revision} = clean_scalar($ENV{HOST_MANAGER_BUILD});
2516
        return \%info;
2517
    }
2518

            
2519
    my $build_file = "$project_dir/BUILD";
2520
    if (-f $build_file) {
2521
        for my $line (split /\n/, read_file($build_file)) {
2522
            next unless $line =~ /\A([A-Za-z0-9_.-]+)=(.*)\z/;
2523
            $info{$1} = clean_scalar($2);
2524
        }
2525
        return \%info if $info{revision} || $info{built_at};
2526
    }
2527

            
2528
    my $revision = git_value('rev-parse --short=12 HEAD');
2529
    my $branch = git_value('rev-parse --abbrev-ref HEAD');
2530
    $info{revision} = $revision if $revision;
2531
    $info{branch} = $branch if $branch && $branch ne 'HEAD';
2532
    return \%info;
2533
}
2534

            
2535
sub git_value {
2536
    my ($args) = @_;
2537
    return '' unless -d "$project_dir/.git";
2538
    open my $fh, '-|', "git -C '$project_dir' $args 2>/dev/null" or return '';
2539
    my $value = <$fh> || '';
2540
    close $fh;
2541
    chomp $value;
2542
    return clean_scalar($value);
2543
}
2544

            
2545
sub build_label {
2546
    my $info = build_info();
2547
    my $revision = $info->{revision} || 'unknown';
2548
    my $branch = $info->{branch} || '';
2549
    $branch = '' if $branch eq 'HEAD';
2550
    my $label = $branch ? "$branch $revision" : $revision;
2551
    $label .= '+dirty' if ($info->{dirty} || '') eq '1';
2552
    return $label;
2553
}
2554

            
2555
sub build_title {
2556
    my $info = build_info();
2557
    my $label = build_label();
2558
    my $stamp = $info->{deployed_at} || $info->{built_at} || '';
2559
    return $stamp ? "$label deployed $stamp" : $label;
2560
}
2561

            
Bogdan Timofte authored 4 days ago
2562
sub build_revision {
2563
    my $info = build_info();
2564
    return $info->{revision} || 'unknown';
2565
}
2566

            
2567
sub build_details {
2568
    my $info = build_info();
2569
    my %details = (
2570
        app => 'Madagascar Local Authority',
2571
        revision => $info->{revision} || 'unknown',
2572
        branch => $info->{branch} || '',
2573
        dirty => ($info->{dirty} || '') eq '1' ? json_bool(1) : json_bool(0),
2574
        built_at => $info->{built_at} || '',
2575
        deployed_at => $info->{deployed_at} || '',
2576
        label => build_label(),
2577
        title => build_title(),
2578
    );
2579
    return json_encode(\%details);
2580
}
2581

            
Bogdan Timofte authored 6 days ago
2582
sub html_escape {
2583
    my ($value) = @_;
2584
    $value = '' unless defined $value;
2585
    $value =~ s/&/&amp;/g;
2586
    $value =~ s/</&lt;/g;
2587
    $value =~ s/>/&gt;/g;
2588
    $value =~ s/"/&quot;/g;
2589
    $value =~ s/'/&#039;/g;
2590
    return $value;
2591
}
2592

            
Xdev Host Manager authored a week ago
2593
sub app_html {
Bogdan Timofte authored 4 days ago
2594
    my $build = html_escape(build_revision());
Bogdan Timofte authored 6 days ago
2595
    my $build_title = html_escape(build_title());
Bogdan Timofte authored 4 days ago
2596
    my $build_details = html_escape(build_details());
Bogdan Timofte authored 6 days ago
2597
    my $html = <<'HTML';
Xdev Host Manager authored a week ago
2598
<!doctype html>
2599
<html lang="ro">
2600
<head>
2601
  <meta charset="utf-8">
2602
  <meta name="viewport" content="width=device-width, initial-scale=1">
Bogdan Timofte authored 6 days ago
2603
  <meta name="xdev-build" content="__HOST_MANAGER_BUILD_TITLE__">
Xdev Host Manager authored a week ago
2604
  <title>Madagascar Local Authority</title>
Xdev Host Manager authored a week ago
2605
  <style>
2606
    :root {
2607
      color-scheme: light;
2608
      --ink: #152033;
2609
      --muted: #647084;
2610
      --line: #d8dee8;
2611
      --soft: #f4f6f9;
2612
      --panel: #ffffff;
2613
      --accent: #1267d8;
2614
      --bad: #b42318;
2615
      --warn: #946200;
2616
      --ok: #137333;
2617
    }
2618
    * { box-sizing: border-box; }
2619
    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
2620

            
2621
    /* ── Login screen ── */
2622
    #login-screen {
2623
      display: flex;
Xdev Host Manager authored a week ago
2624
      align-items: flex-start;
Xdev Host Manager authored a week ago
2625
      justify-content: center;
2626
      min-height: 100dvh;
Xdev Host Manager authored a week ago
2627
      padding: clamp(48px, 10vh, 96px) 24px clamp(140px, 20vh, 220px);
Xdev Host Manager authored a week ago
2628
      background: #13182a;
Xdev Host Manager authored a week ago
2629
      overflow: auto;
Xdev Host Manager authored a week ago
2630
    }
2631
    .login-card {
Xdev Host Manager authored a week ago
2632
      --otp-size: 48px;
Xdev Host Manager authored a week ago
2633
      --otp-gap: 18px;
Xdev Host Manager authored a week ago
2634
      --login-form-width: calc((var(--otp-size) * 6) + (var(--otp-gap) * 5));
Xdev Host Manager authored a week ago
2635
      background: #fff;
2636
      border-radius: 16px;
Bogdan Timofte authored 4 days ago
2637
      /* Extra bottom room so Safari's OTP autofill banner, which overlays just
2638
         below the first box, sits inside the card instead of spilling past it. */
2639
      padding: 54px 64px 110px;
Xdev Host Manager authored a week ago
2640
      width: 100%;
Xdev Host Manager authored a week ago
2641
      max-width: 680px;
Bogdan Timofte authored 6 days ago
2642
      min-height: 360px;
Xdev Host Manager authored a week ago
2643
      display: grid;
Xdev Host Manager authored a week ago
2644
      align-content: start;
2645
      justify-items: center;
2646
      gap: 28px;
Xdev Host Manager authored a week ago
2647
      box-shadow: 0 8px 40px rgba(0,0,0,.28);
2648
    }
Xdev Host Manager authored a week ago
2649
    .login-card .brand { text-align: center; display: grid; gap: 8px; justify-items: center; }
Xdev Host Manager authored a week ago
2650
    .login-card .brand .icon {
Xdev Host Manager authored a week ago
2651
      margin: 0 0 8px;
Xdev Host Manager authored a week ago
2652
      width: 64px; height: 64px; border-radius: 18px;
Xdev Host Manager authored a week ago
2653
      background: #e8f0fe; display: flex; align-items: center; justify-content: center;
2654
    }
Xdev Host Manager authored a week ago
2655
    .login-card .brand .icon svg { width: 38px; height: 38px; fill: none; stroke: var(--accent); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
2656
    .login-card .brand h1 { margin: 0; font-size: 32px; line-height: 1.05; font-weight: 750; color: var(--ink); }
2657
    .login-card .brand p { margin: 0; color: var(--muted); font-size: 16px; }
Xdev Host Manager authored a week ago
2658
    .login-card form {
2659
      display: grid;
2660
      width: min(100%, var(--login-form-width));
Xdev Host Manager authored a week ago
2661
      justify-self: center;
Bogdan Timofte authored a week ago
2662
      padding-bottom: 0;
Xdev Host Manager authored a week ago
2663
    }
Xdev Host Manager authored a week ago
2664
    .login-card form.busy { opacity: .72; pointer-events: none; }
Bogdan Timofte authored 4 days ago
2665
    /* Off-screen helper fields keep the visible UI to the 6 OTP boxes while still
2666
       giving the password manager a username anchor and an aggregated OTP target
2667
       (see development-log: "Password-Manager-Friendly Form Shape"). */
Bogdan Timofte authored 6 days ago
2668
    .pm-helper-fields {
2669
      position: absolute;
2670
      left: -10000px;
2671
      top: auto;
2672
      width: 1px;
2673
      height: 1px;
2674
      overflow: hidden;
2675
      opacity: 0.01;
2676
    }
2677
    .pm-helper-fields input {
2678
      width: 1px;
2679
      height: 1px;
2680
      padding: 0;
2681
      border: 0;
2682
    }
Bogdan Timofte authored 4 days ago
2683
    /* 6 separate OTP digit boxes. No autocomplete="one-time-code" on them: that
2684
       hint was what made Safari mark the whole group and re-present its OTP
2685
       autofill on every focused box. Without it, the banner stays on the first. */
Xdev Host Manager authored a week ago
2686
    .otp-row {
2687
      display: flex;
2688
      gap: var(--otp-gap);
2689
      justify-content: center;
2690
    }
Bogdan Timofte authored 5 days ago
2691
    .otp-row input {
Xdev Host Manager authored a week ago
2692
      width: var(--otp-size); height: 56px; border: 1.5px solid #dde2ec; border-radius: 10px;
Bogdan Timofte authored 5 days ago
2693
      font-size: 22px; font-weight: 600; text-align: center; color: var(--ink);
2694
      background: #f8fafc; caret-color: transparent; outline: none;
Xdev Host Manager authored a week ago
2695
      transition: border-color .15s, background .15s;
2696
    }
Bogdan Timofte authored 5 days ago
2697
    .otp-row input:focus { border-color: var(--accent); background: #fff; }
2698
    .otp-row input.filled { border-color: #b3c6f0; background: #fff; }
Xdev Host Manager authored a week ago
2699
    #login-error {
2700
      color: var(--bad); font-size: 13px; text-align: center;
Bogdan Timofte authored 4 days ago
2701
      min-height: 18px; margin: -14px 0;
Xdev Host Manager authored a week ago
2702
    }
2703
    @media (max-width: 760px) {
2704
      .login-card {
Xdev Host Manager authored a week ago
2705
        max-width: 520px;
Xdev Host Manager authored a week ago
2706
        min-height: 0;
Bogdan Timofte authored 4 days ago
2707
        padding: 48px 36px 100px;
Xdev Host Manager authored a week ago
2708
        gap: 26px;
2709
      }
2710
      .login-card .brand h1 { font-size: 24px; }
2711
      .login-card .brand p { font-size: 14px; }
Bogdan Timofte authored a week ago
2712
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2713
    }
Xdev Host Manager authored a week ago
2714
    @media (max-width: 430px) {
2715
      #login-screen { padding: 24px 16px 120px; }
2716
      .login-card {
2717
        --otp-size: 42px;
Xdev Host Manager authored a week ago
2718
        --otp-gap: 12px;
Bogdan Timofte authored 4 days ago
2719
        padding: 36px 22px 92px;
Xdev Host Manager authored a week ago
2720
      }
Bogdan Timofte authored 5 days ago
2721
      .otp-row input { height: 52px; }
Bogdan Timofte authored a week ago
2722
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2723
    }
2724
    @media (max-height: 720px) {
2725
      #login-screen { padding-top: 28px; padding-bottom: 96px; }
Bogdan Timofte authored 4 days ago
2726
      .login-card { padding-top: 34px; padding-bottom: 84px; gap: 20px; }
Bogdan Timofte authored a week ago
2727
      .login-card form { padding-bottom: 0; }
Xdev Host Manager authored a week ago
2728
    }
Xdev Host Manager authored a week ago
2729

            
2730
    /* ── App shell (hidden until authenticated) ── */
2731
    #app { display: none; }
Bogdan Timofte authored 5 days ago
2732
    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
2733
    h1 { margin: 0; font-size: 17px; font-weight: 700; }
Bogdan Timofte authored 5 days ago
2734
    nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
2735
    nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
2736
    nav a:hover { color: var(--ink); background: var(--soft); }
2737
    nav a.active { color: var(--accent); background: #e8f0fe; }
2738
    .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
2739
    #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
Xdev Host Manager authored a week ago
2740
    main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
Bogdan Timofte authored 5 days ago
2741
    .page { display: grid; gap: 16px; }
2742
    .page[hidden] { display: none; }
Xdev Host Manager authored a week ago
2743
    .toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
2744
    .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
2745
    .panel { overflow: hidden; }
2746
    .panel-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--line); background: #fafbfc; }
2747
    .panel-head h2 { margin: 0; font-size: 14px; }
2748
    .stats { display: flex; gap: 8px; flex-wrap: wrap; }
2749
    .stat { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--soft); font-size: 12px; color: var(--muted); }
2750
    button, input, select, textarea { font: inherit; }
2751
    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; }
2752
    button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
Xdev Host Manager authored a week ago
2753
    button:disabled { opacity: .45; cursor: not-allowed; }
Xdev Host Manager authored a week ago
2754
    button.danger { color: var(--bad); }
Xdev Host Manager authored a week ago
2755
    button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
Xdev Host Manager authored a week ago
2756
    input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
2757
    textarea { min-height: 74px; resize: vertical; }
2758
    table { width: 100%; border-collapse: collapse; table-layout: fixed; }
2759
    th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
2760
    th { color: var(--muted); font-size: 12px; font-weight: 700; background: #fafbfc; }
2761
    tr:hover td { background: #f8fafc; }
2762
    .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; }
2763
    .pill.ok { color: var(--ok); border-color: #b7dfc1; background: #edf8ef; }
2764
    .pill.warn { color: var(--warn); border-color: #f1d184; background: #fff7df; }
2765
    .pill.bad { color: var(--bad); border-color: #f0b8b3; background: #fff0ee; }
Bogdan Timofte authored 5 days ago
2766
    .pill.derived { border-style: dashed; }
Bogdan Timofte authored 4 days ago
2767
    .pill.canonical { font-weight: 700; }
2768
    .pill.vhost { background: #eef7ff; border-color: #b6d6f7; color: #0e4f96; }
Xdev Host Manager authored a week ago
2769
    .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 14px; }
2770
    .span2 { grid-column: 1 / -1; }
2771
    label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
2772
    .muted { color: var(--muted); }
Bogdan Timofte authored 5 days ago
2773
    .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
2774
    .ca-detail { display: grid; gap: 6px; min-width: 0; }
2775
    .ca-fingerprint { overflow-wrap: anywhere; }
2776
    .ca-empty { padding: 12px 14px; }
Bogdan Timofte authored 4 days ago
2777
    .build-control {
Bogdan Timofte authored 6 days ago
2778
      position: fixed;
2779
      right: 10px;
2780
      bottom: 8px;
2781
      z-index: 5;
Bogdan Timofte authored 4 days ago
2782
      display: inline-flex;
2783
      align-items: center;
2784
      gap: 4px;
2785
    }
2786
    .build-badge, .build-copy {
Bogdan Timofte authored 6 days ago
2787
      color: rgba(255,255,255,.46);
2788
      background: rgba(19,24,42,.28);
2789
      border: 1px solid rgba(255,255,255,.08);
2790
      border-radius: 4px;
2791
      font-size: 10px;
2792
      line-height: 1.2;
Bogdan Timofte authored 4 days ago
2793
    }
2794
    .build-badge {
2795
      padding: 2px 5px;
Bogdan Timofte authored 5 days ago
2796
      cursor: text;
2797
      user-select: text;
Bogdan Timofte authored 6 days ago
2798
    }
Bogdan Timofte authored 4 days ago
2799
    .build-copy {
2800
      min-height: 0;
2801
      padding: 2px 5px;
2802
      cursor: pointer;
2803
    }
2804
    .build-copy:hover {
2805
      color: rgba(255,255,255,.72);
2806
      border-color: rgba(255,255,255,.24);
2807
    }
2808
    body.is-app .build-badge, body.is-app .build-copy {
Bogdan Timofte authored 6 days ago
2809
      color: rgba(100,112,132,.58);
2810
      background: rgba(255,255,255,.72);
2811
      border-color: rgba(216,222,232,.72);
2812
    }
Bogdan Timofte authored 4 days ago
2813
    body.is-app .build-copy:hover {
2814
      color: rgba(21,32,51,.78);
2815
      border-color: rgba(100,112,132,.42);
2816
    }
Xdev Host Manager authored a week ago
2817
    .problems { padding: 10px 14px; display: grid; gap: 8px; }
2818
    .problem { border-left: 3px solid var(--warn); padding: 7px 9px; background: #fffaf0; }
Bogdan Timofte authored 6 days ago
2819
    .work-order-card { display: grid; gap: 8px; min-width: 0; }
2820
    .work-order-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
2821
    .work-order-title { color: var(--ink); font-size: 14px; font-weight: 650; }
2822
    .work-order-checklist, .work-order-actions { display: grid; gap: 6px; min-width: 0; }
2823
    .work-order-actions { gap: 4px; }
2824
    .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2825
    .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2826
    .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
Bogdan Timofte authored 4 days ago
2827
    .debug-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; width: 100%; }
Bogdan Timofte authored 4 days ago
2828
    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
Bogdan Timofte authored 4 days ago
2829
    .debug-table-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; padding: 10px; border-top: 1px solid var(--line); }
Bogdan Timofte authored 4 days ago
2830
    .debug-table-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 58px; padding: 8px; border: 1px solid var(--line); border-radius: 6px; background: #fff; }
Bogdan Timofte authored 4 days ago
2831
    .debug-table-card:hover { border-color: #9fb7e9; background: #f8fbff; }
2832
    .debug-table-card.active { border-color: var(--accent); background: #e8f0fe; box-shadow: inset 0 0 0 1px var(--accent); }
Bogdan Timofte authored 4 days ago
2833
    .debug-table-card-main { display: grid; align-content: center; justify-items: start; gap: 5px; min-width: 0; min-height: 42px; width: 100%; padding: 4px 6px; border: 0; background: transparent; text-align: left; }
2834
    .debug-table-card-main:hover { background: transparent; }
Bogdan Timofte authored 4 days ago
2835
    .debug-table-card-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); font-weight: 700; }
2836
    .debug-table-card-rows { color: var(--muted); font-size: 12px; }
Bogdan Timofte authored 4 days ago
2837
    .debug-table-copy { position: relative; min-width: 34px; width: 34px; justify-content: center; padding: 7px; color: var(--muted); font-size: 0; }
2838
    .debug-table-copy::before, .debug-table-copy::after { content: ""; position: absolute; width: 12px; height: 14px; border: 1.6px solid currentColor; border-radius: 2px; box-sizing: border-box; }
2839
    .debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
2840
    .debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
Bogdan Timofte authored 4 days ago
2841
    .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2842
    .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
Bogdan Timofte authored 4 days ago
2843
    .debug-section { display: grid; gap: 16px; }
Bogdan Timofte authored 5 days ago
2844
    .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2845
    .host-tools input { max-width: 240px; }
Bogdan Timofte authored 4 days ago
2846
    #page-vhosts .panel-head { align-items: center; padding-block: 10px; }
2847
    #page-vhosts .host-tools { flex-wrap: wrap; }
2848
    #page-vhosts .host-tools input { max-width: 280px; }
2849
    #page-vhosts .stats { justify-content: flex-end; }
2850
    .vhost-host { display: grid; gap: 2px; }
2851
    .vhost-pill-row { display: flex; flex-wrap: wrap; gap: 4px; }
2852
    .vhost-pill-row .pill { margin: 0; }
Bogdan Timofte authored 4 days ago
2853
    .vhost-host-select { width: 100%; max-width: 100%; min-height: 34px; }
Bogdan Timofte authored 4 days ago
2854
    .vhost-inline-editor { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--line); background: #fff; }
2855
    .vhost-delete { color: var(--bad); }
Bogdan Timofte authored 4 days ago
2856
    .host-editor-panel { margin-top: 16px; }
2857
    .host-editor-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
2858
    .host-editor-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Bogdan Timofte authored 5 days ago
2859
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2860
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2861
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2862
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2863
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2864
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2865
      #message { max-width: 100%; }
2866
      .panel-head { align-items: stretch; flex-direction: column; }
2867
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2868
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2869
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 4 days ago
2870
      .host-editor-head { align-items: stretch; flex-direction: column; }
2871
      .host-editor-tools { justify-content: flex-start; }
Bogdan Timofte authored 4 days ago
2872
      .debug-controls { align-items: stretch; }
Xdev Host Manager authored a week ago
2873
      .grid { grid-template-columns: 1fr; }
2874
      table { min-width: 760px; }
2875
      .table-wrap { overflow-x: auto; }
2876
    }
2877
  </style>
2878
</head>
Bogdan Timofte authored 6 days ago
2879
<body class="is-login">
Xdev Host Manager authored a week ago
2880

            
Xdev Host Manager authored a week ago
2881
  <!-- ── Login screen ── -->
2882
  <div id="login-screen">
2883
    <div class="login-card">
2884
      <div class="brand">
2885
        <div class="icon">
Xdev Host Manager authored a week ago
2886
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2887
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2888
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2889
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2890
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2891
            <path d="M26 20h8M26 32h8M26 44h8"/>
2892
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2893
          </svg>
2894
        </div>
Xdev Host Manager authored a week ago
2895
        <h1>Madagascar Local Authority</h1>
2896
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2897
      </div>
Bogdan Timofte authored 4 days ago
2898
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2899
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2900
        <div class="pm-helper-fields" aria-hidden="true">
2901
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2902
          <input type="hidden" id="otp-hidden" name="otp">
2903
        </div>
Xdev Host Manager authored a week ago
2904
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2905
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2906
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2907
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2908
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2909
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2910
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2911
        </div>
2912
      </form>
2913
    </div>
2914
  </div>
2915

            
2916
  <!-- ── App (shown after login) ── -->
2917
  <div id="app">
2918
    <header>
Xdev Host Manager authored a week ago
2919
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2920
      <nav aria-label="Sections">
2921
        <a href="/overview" data-page-link="overview">Overview</a>
2922
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
2923
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
2924
        <a href="/dns" data-page-link="dns">DNS</a>
2925
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2926
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2927
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2928
      </nav>
Xdev Host Manager authored a week ago
2929
      <div class="header-right">
2930
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2931
        <span id="message" class="muted"></span>
2932
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2933
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2934
      </div>
Xdev Host Manager authored a week ago
2935
    </header>
2936
    <main>
Bogdan Timofte authored 5 days ago
2937
      <section class="page" id="page-overview" data-page="overview">
2938
        <section class="panel">
2939
          <div class="panel-head">
2940
            <h2>Overview</h2>
2941
            <div class="stats" id="stats"></div>
2942
          </div>
2943
          <div class="problems" id="problems"></div>
2944
        </section>
Xdev Host Manager authored a week ago
2945
      </section>
2946

            
Bogdan Timofte authored 5 days ago
2947
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2948
        <section class="panel">
2949
          <div class="panel-head">
2950
            <h2>Hosts</h2>
2951
            <div class="host-tools">
2952
              <input id="filter" placeholder="filter">
2953
              <button type="button" id="new-host">New host</button>
2954
            </div>
2955
          </div>
2956
          <div class="table-wrap">
2957
            <table>
2958
              <thead>
2959
                <tr>
2960
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
2961
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
2962
                  <th>Names</th>
2963
                  <th style="width: 150px">Roles</th>
2964
                  <th style="width: 110px">Monitoring</th>
2965
                  <th style="width: 90px">Status</th>
2966
                </tr>
2967
              </thead>
2968
              <tbody id="hosts"></tbody>
2969
            </table>
2970
          </div>
2971
        </section>
Bogdan Timofte authored 4 days ago
2972
        <section class="panel host-editor-panel">
2973
          <div class="panel-head host-editor-head">
2974
            <h2 id="host-form-title">New host</h2>
2975
            <div class="host-editor-tools">
2976
              <button type="button" id="reset-host-form">Reset</button>
2977
            </div>
2978
          </div>
2979
          <form id="host-form" class="grid">
2980
            <label>ID<input name="id" required></label>
2981
            <label>FQDN<input name="fqdn" required></label>
2982
            <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
2983
            <label>IP<input name="ip" required></label>
2984
            <label class="span2">Aliases<textarea name="aliases"></textarea></label>
2985
            <label>Roles<input name="roles"></label>
2986
            <label>Sources<input name="sources"></label>
2987
            <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
2988
            <label>Notes<input name="notes"></label>
2989
            <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
2990
            <div class="span2 form-actions">
2991
              <button class="primary" type="submit" id="save-host">Save host</button>
2992
              <button class="danger" type="button" id="delete-host">Delete host</button>
2993
            </div>
2994
          </form>
2995
        </section>
Xdev Host Manager authored a week ago
2996
      </section>
Xdev Host Manager authored a week ago
2997

            
Bogdan Timofte authored 4 days ago
2998
      <section class="page" id="page-vhosts" data-page="vhosts" hidden>
2999
        <section class="panel">
3000
          <div class="panel-head">
3001
            <h2>Vhosts</h2>
3002
            <div class="host-tools">
3003
              <input id="vhost-filter" placeholder="filter">
3004
              <div class="stats" id="vhost-stats"></div>
3005
            </div>
3006
          </div>
Bogdan Timofte authored 4 days ago
3007
          <div class="vhost-inline-editor">
3008
            <input id="vhost-new-name" placeholder="vhost fqdn">
3009
            <select id="vhost-new-host"></select>
3010
            <button type="button" id="vhost-add">Add</button>
3011
          </div>
Bogdan Timofte authored 4 days ago
3012
          <div class="table-wrap">
3013
            <table>
3014
              <thead>
3015
                <tr>
3016
                  <th>Vhost</th>
3017
                  <th style="width: 190px">Host</th>
3018
                  <th style="width: 140px">IP</th>
3019
                  <th style="width: 180px">Derived aliases</th>
3020
                  <th style="width: 120px">Monitoring</th>
3021
                  <th style="width: 90px">Status</th>
Bogdan Timofte authored 4 days ago
3022
                  <th style="width: 90px">Actions</th>
Bogdan Timofte authored 4 days ago
3023
                </tr>
3024
              </thead>
3025
              <tbody id="vhosts"></tbody>
3026
            </table>
3027
          </div>
3028
        </section>
3029
      </section>
3030

            
Bogdan Timofte authored 5 days ago
3031
      <section class="page" id="page-dns" data-page="dns" hidden>
3032
        <section class="toolbar">
3033
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3034
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3035
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3036
          <button id="write-tsv">Write local-hosts.tsv</button>
3037
        </section>
Xdev Host Manager authored a week ago
3038
      </section>
3039

            
Bogdan Timofte authored 5 days ago
3040
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3041
        <section class="panel">
3042
          <div class="panel-head">
3043
            <h2>Work Orders</h2>
3044
            <div class="stats" id="wo-stats"></div>
3045
          </div>
3046
          <div class="problems" id="work-orders"></div>
3047
        </section>
Xdev Host Manager authored a week ago
3048
      </section>
3049

            
Bogdan Timofte authored 5 days ago
3050
      <section class="page" id="page-ca" data-page="ca" hidden>
3051
        <section class="panel">
3052
          <div class="panel-head">
3053
            <h2>Local Certificate Authority</h2>
3054
            <a class="linkbtn" href="/download/ca.crt">ca.crt</a>
3055
          </div>
3056
          <div class="problems" id="ca-status"></div>
3057
        </section>
3058
        <section class="panel">
3059
          <div class="panel-head">
3060
            <h2>Issued Certificates</h2>
3061
            <div class="stats" id="ca-certs-summary"></div>
3062
          </div>
3063
          <div class="table-wrap">
3064
            <table>
3065
              <thead>
3066
                <tr>
3067
                  <th style="width: 150px">Name</th>
3068
                  <th>DNS names</th>
3069
                  <th style="width: 210px">Validity</th>
3070
                  <th style="width: 180px">Serial</th>
3071
                  <th>Fingerprint</th>
3072
                  <th style="width: 110px">Download</th>
3073
                </tr>
3074
              </thead>
3075
              <tbody id="ca-certs"></tbody>
3076
            </table>
3077
          </div>
3078
        </section>
Xdev Host Manager authored a week ago
3079
      </section>
Bogdan Timofte authored 4 days ago
3080

            
3081
      <section class="page" id="page-debug" data-page="debug" hidden>
3082
        <section class="panel">
3083
          <div class="panel-head">
3084
            <h2>Database</h2>
3085
            <div class="stats" id="debug-db-stats"></div>
3086
          </div>
3087
          <div class="toolbar">
3088
            <div class="debug-controls">
3089
              <button type="button" id="debug-db-refresh">Refresh</button>
3090
              <div class="debug-meta muted mono" id="debug-db-meta"></div>
3091
            </div>
3092
          </div>
Bogdan Timofte authored 4 days ago
3093
          <div class="debug-table-cards" id="debug-db-tables"></div>
Bogdan Timofte authored 4 days ago
3094
        </section>
3095
        <section class="debug-section">
3096
          <section class="panel">
3097
            <div class="panel-head">
3098
              <h2>Rows</h2>
Bogdan Timofte authored 4 days ago
3099
              <div class="debug-table-head-actions">
3100
                <div class="stats" id="debug-table-stats"></div>
3101
                <div class="debug-table-exports">
3102
                  <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a>
3103
                  <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a>
3104
                </div>
3105
              </div>
Bogdan Timofte authored 4 days ago
3106
            </div>
3107
            <div class="table-wrap" id="debug-table-rows"></div>
3108
          </section>
3109
          <section class="panel">
3110
            <div class="panel-head">
3111
              <h2>Columns</h2>
3112
            </div>
3113
            <div class="table-wrap" id="debug-table-columns"></div>
3114
          </section>
3115
          <section class="panel">
3116
            <div class="panel-head">
3117
              <h2>Indexes</h2>
3118
            </div>
3119
            <div class="table-wrap" id="debug-table-indexes"></div>
3120
          </section>
3121
          <section class="panel">
3122
            <div class="panel-head">
3123
              <h2>Foreign Keys</h2>
3124
            </div>
3125
            <div class="table-wrap" id="debug-table-foreign-keys"></div>
3126
          </section>
3127
        </section>
3128
      </section>
Bogdan Timofte authored 5 days ago
3129
    </main>
Xdev Host Manager authored a week ago
3130

            
3131
  </div>
3132

            
Bogdan Timofte authored 4 days ago
3133
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3134
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3135
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3136
  </div>
Bogdan Timofte authored 6 days ago
3137

            
Xdev Host Manager authored a week ago
3138
  <script>
Bogdan Timofte authored 4 days ago
3139
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3140
    let hostFormSnapshot = '';
Bogdan Timofte authored 4 days ago
3141
    let hostFormBusy = false;
3142
    let hostFormMode = 'new';
Xdev Host Manager authored a week ago
3143

            
3144
    const $ = (id) => document.getElementById(id);
3145
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
3146
    const PAGE_PATHS = {
3147
      '/': 'overview',
3148
      '/overview': 'overview',
3149
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3150
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3151
      '/dns': 'dns',
3152
      '/work-orders': 'work-orders',
3153
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3154
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3155
    };
Xdev Host Manager authored a week ago
3156

            
Bogdan Timofte authored 4 days ago
3157
    function isAuthLost(error) {
3158
      return !!(error && error.authLost);
3159
    }
3160

            
3161
    function authLostError(message) {
3162
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3163
      error.authLost = true;
3164
      return error;
3165
    }
3166

            
3167
    function handleAuthLost(message) {
3168
      state.authenticated = false;
3169
      msg('');
3170
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3171
    }
3172

            
Bogdan Timofte authored 4 days ago
3173
    async function ensureAuthenticated(message) {
3174
      if (!state.authenticated) {
3175
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3176
        return false;
3177
      }
3178
      const session = await api('/api/session');
3179
      state.authenticated = session.authenticated;
3180
      if (!state.authenticated) {
3181
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3182
        return false;
3183
      }
3184
      return true;
3185
    }
3186

            
Xdev Host Manager authored a week ago
3187
    async function api(path, options = {}) {
3188
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3189
      let body = {};
3190
      try {
3191
        body = await res.json();
3192
      } catch (_) {
3193
        body = {};
3194
      }
3195
      const errorCode = body.error || '';
3196
      if (!res.ok) {
3197
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3198
          const error = authLostError();
3199
          handleAuthLost(error.message);
3200
          throw error;
3201
        }
3202
        throw new Error(errorCode || res.statusText);
3203
      }
Xdev Host Manager authored a week ago
3204
      return body;
3205
    }
3206

            
Bogdan Timofte authored 5 days ago
3207
    function currentPage() {
3208
      return PAGE_PATHS[window.location.pathname] || 'overview';
3209
    }
3210

            
3211
    function showPage(page, push = false) {
3212
      const target = page || 'overview';
3213
      document.querySelectorAll('[data-page]').forEach(section => {
3214
        section.hidden = section.dataset.page !== target;
3215
      });
3216
      document.querySelectorAll('[data-page-link]').forEach(link => {
3217
        link.classList.toggle('active', link.dataset.pageLink === target);
3218
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3219
      });
3220
      if (push) {
3221
        const href = target === 'overview' ? '/overview' : '/' + target;
3222
        history.pushState({ page: target }, '', href);
3223
      }
Bogdan Timofte authored 4 days ago
3224
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3225
        renderDebugDatabase().catch(e => {
3226
          if (!isAuthLost(e)) msg(e.message);
3227
        });
Bogdan Timofte authored 4 days ago
3228
      }
Bogdan Timofte authored 5 days ago
3229
    }
3230

            
Xdev Host Manager authored a week ago
3231
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3232
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3233
      document.body.classList.remove('is-app');
3234
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3235
      $('app').style.display = 'none';
3236
      $('login-screen').style.display = 'flex';
3237
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3238
      clearOtp();
Xdev Host Manager authored a week ago
3239
    }
3240

            
3241
    function showApp() {
Bogdan Timofte authored 6 days ago
3242
      document.body.classList.remove('is-login');
3243
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3244
      $('login-screen').style.display = 'none';
3245
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3246
      showPage(currentPage());
Xdev Host Manager authored a week ago
3247
    }
3248

            
Xdev Host Manager authored a week ago
3249
    async function refresh() {
3250
      const session = await api('/api/session');
3251
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3252
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3253
      showApp();
Xdev Host Manager authored a week ago
3254
      const data = await api('/api/hosts');
3255
      state.hosts = data.hosts || [];
3256
      state.problems = data.problems || [];
3257
      render(data);
Xdev Host Manager authored a week ago
3258
      await renderCa();
Xdev Host Manager authored a week ago
3259
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3260
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3261
    }
3262

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

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

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

            
3276
      renderHosts();
Bogdan Timofte authored 4 days ago
3277
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3278
      renderVhosts();
Xdev Host Manager authored a week ago
3279
    }
3280

            
Xdev Host Manager authored a week ago
3281
    async function renderCa() {
3282
      try {
3283
        const status = await api('/api/ca/status');
3284
        if (!status.initialized) {
3285
          $('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
Bogdan Timofte authored 5 days ago
3286
          $('ca-certs-summary').innerHTML = '';
3287
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3288
          return;
3289
        }
3290
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
3291
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3292
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3293
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3294
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3295
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3296
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3297
            <div>
3298
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3299
              <span>${certs.length} issued certificate(s)</span>
3300
            </div>
Xdev Host Manager authored a week ago
3301
          </div>`;
Bogdan Timofte authored 5 days ago
3302
        $('ca-certs-summary').innerHTML = [
3303
          ['issued', certs.length],
3304
          ['expiring', certs.filter(cert => {
3305
            const days = daysUntil(cert.not_after);
3306
            return days !== null && days >= 0 && days <= 30;
3307
          }).length],
3308
          ['expired', certs.filter(cert => {
3309
            const days = daysUntil(cert.not_after);
3310
            return days !== null && days < 0;
3311
          }).length],
3312
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3313
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3314
          const days = daysUntil(cert.not_after);
3315
          const dnsNames = cert.dns_names || [];
3316
          const dnsHtml = dnsNames.length
3317
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3318
            : '<span class="muted">No DNS SANs reported.</span>';
3319
          return `<tr>
3320
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3321
            <td>${dnsHtml}</td>
3322
            <td>
3323
              <div class="ca-detail">
3324
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3325
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3326
              </div>
3327
            </td>
3328
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3329
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
3330
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
3331
          </tr>`;
3332
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3333
      } catch (e) {
Bogdan Timofte authored 4 days ago
3334
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3335
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3336
        $('ca-certs-summary').innerHTML = '';
3337
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3338
      }
3339
    }
3340

            
Bogdan Timofte authored 5 days ago
3341
    function daysUntil(dateText) {
3342
      const time = Date.parse(dateText || '');
3343
      if (!Number.isFinite(time)) return null;
3344
      return Math.ceil((time - Date.now()) / 86400000);
3345
    }
3346

            
3347
    function certStatusClass(days) {
3348
      if (days === null) return '';
3349
      if (days < 0) return 'bad';
3350
      if (days <= 30) return 'warn';
3351
      return 'ok';
3352
    }
3353

            
3354
    function certStatusLabel(days) {
3355
      if (days === null) return 'validity unknown';
3356
      if (days < 0) return 'expired';
3357
      if (days === 0) return 'expires today';
3358
      return `${days}d remaining`;
3359
    }
3360

            
Xdev Host Manager authored a week ago
3361
    async function renderWorkOrders() {
3362
      try {
3363
        const data = await api('/api/work-orders');
3364
        state.workOrders = data.work_orders || [];
3365
        $('wo-stats').innerHTML = [
3366
          ['pending', data.counts.pending],
3367
          ['total', data.counts.work_orders],
3368
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3369

            
3370
        if (!state.workOrders.length) {
3371
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3372
          return;
3373
        }
3374

            
3375
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3376
          const checklist = wo.checklist || [];
3377
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3378
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3379
          const checklistHtml = checklist.map(item => {
3380
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3381
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3382
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3383
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3384
            </label>`;
3385
          }).join('');
Xdev Host Manager authored a week ago
3386
          const actions = (wo.actions || []).map(a => {
3387
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3388
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3389
          }).join('');
3390
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3391
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3392
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3393
            : '';
Bogdan Timofte authored 6 days ago
3394
          return `<div class="problem work-order-card">
3395
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3396
              <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
3397
              ${button}
3398
            </div>
Bogdan Timofte authored 6 days ago
3399
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3400
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3401
            <div class="work-order-checklist">${checklistHtml}</div>
3402
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3403
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3404
          </div>`;
3405
        }).join('');
Xdev Host Manager authored a week ago
3406
        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
3407
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3408
      } catch (e) {
Bogdan Timofte authored 4 days ago
3409
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3410
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3411
      }
3412
    }
3413

            
Bogdan Timofte authored 4 days ago
3414
    async function renderDebugDatabase() {
3415
      if (!state.authenticated) return;
3416
      const data = await api('/api/debug/database/tables');
3417
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3418
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3419
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3420
      $('debug-db-stats').innerHTML = [
3421
        ['tables', data.counts ? data.counts.tables : tables.length],
3422
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3423
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3424
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3425
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3426
      if (selected) {
3427
        await renderDebugTable(selected);
3428
      } else {
3429
        clearDebugTable();
3430
      }
3431
    }
3432

            
Bogdan Timofte authored 4 days ago
3433
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3434
      $('debug-db-tables').innerHTML = tables.length
3435
        ? tables.map(table => {
3436
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3437
            const ref = debugTableReference(database, table.name);
3438
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3439
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3440
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3441
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3442
              </button>
Bogdan Timofte authored 4 days ago
3443
              <button type="button" class="debug-table-copy" data-debug-table-ref="${escapeHtml(ref)}" title="${escapeHtml(ref)}" aria-label="Copy full table reference for ${escapeHtml(table.name)}"></button>
Bogdan Timofte authored 4 days ago
3444
            </div>`;
Bogdan Timofte authored 4 days ago
3445
          }).join('')
3446
        : '<div class="ca-empty muted">No database tables found.</div>';
3447
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3448
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3449
          if (!isAuthLost(e)) msg(e.message);
3450
        }));
3451
      });
Bogdan Timofte authored 4 days ago
3452
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3453
        button.addEventListener('click', async () => {
3454
          try {
3455
            await copyText(button.dataset.debugTableRef || '');
3456
            msg('table reference copied');
3457
          } catch (e) {
3458
            msg('copy failed');
3459
          }
3460
        });
3461
      });
3462
    }
3463

            
3464
    function debugTableReference(database, tableName) {
3465
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3466
    }
3467

            
3468
    async function selectDebugTable(tableName) {
3469
      state.debugTable = tableName || '';
3470
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3471
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3472
        const card = button.closest('.debug-table-card');
3473
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3474
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3475
      });
3476
      if (state.debugTable) await renderDebugTable(state.debugTable);
3477
    }
3478

            
3479
    function clearDebugTable() {
3480
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3481
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3482
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3483
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3484
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3485
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3486
    }
3487

            
3488
    async function renderDebugTable(tableName) {
3489
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3490
      if (data.error) throw new Error(data.error);
3491
      $('debug-table-stats').innerHTML = [
3492
        ['table', data.table || tableName],
3493
        ['rows', data.row_count || 0],
3494
        ['shown', (data.rows || []).length],
3495
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3496
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3497
      renderDebugRows(data);
3498
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3499
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3500
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3501
    }
3502

            
Bogdan Timofte authored 4 days ago
3503
    function updateDebugExportLinks(tableName) {
3504
      const encoded = encodeURIComponent(tableName || '');
3505
      [
3506
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3507
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3508
      ].forEach(([id, href]) => {
3509
        const link = $(id);
3510
        const enabled = !!tableName;
3511
        link.href = enabled ? href : '#';
3512
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3513
      });
3514
    }
3515

            
Bogdan Timofte authored 4 days ago
3516
    function renderDebugRows(data) {
3517
      const rows = data.rows || [];
3518
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3519
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3520
    }
3521

            
3522
    function renderDebugObjectTable(rows, preferredKeys) {
3523
      const keys = preferredKeys && preferredKeys.length
3524
        ? preferredKeys
3525
        : Array.from(rows.reduce((set, row) => {
3526
            Object.keys(row || {}).forEach(key => set.add(key));
3527
            return set;
3528
          }, new Set()));
3529
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3530
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3531
      const body = rows.length
3532
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3533
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3534
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3535
    }
3536

            
3537
    function debugCell(value) {
3538
      if (value === null || value === undefined) return 'NULL';
3539
      if (Array.isArray(value)) return value.join(', ');
3540
      if (typeof value === 'object') return JSON.stringify(value);
3541
      return String(value);
3542
    }
3543

            
Xdev Host Manager authored a week ago
3544
    async function updateWorkOrderChecklist(id, itemId, checked) {
3545
      try {
3546
        await api('/api/work-orders/checklist', {
3547
          method: 'POST',
3548
          headers: { 'Content-Type': 'application/json' },
3549
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3550
        });
3551
        msg('work order updated');
3552
        await refresh();
Bogdan Timofte authored 4 days ago
3553
      } catch (e) {
3554
        if (isAuthLost(e)) return;
3555
        msg(e.message);
3556
        await refresh().catch(refreshError => {
3557
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3558
        });
3559
      }
Xdev Host Manager authored a week ago
3560
    }
3561

            
Xdev Host Manager authored a week ago
3562
    async function confirmWorkOrder(id) {
3563
      const typed = prompt(`Type ${id} to confirm this work order`);
3564
      if (typed !== id) return;
3565
      try {
3566
        await api('/api/work-orders/confirm', {
3567
          method: 'POST',
3568
          headers: { 'Content-Type': 'application/json' },
3569
          body: JSON.stringify({ id, confirm: typed })
3570
        });
3571
        msg('work order confirmed; local-hosts.tsv written');
3572
        await refresh();
Bogdan Timofte authored 4 days ago
3573
      } catch (e) {
3574
        if (isAuthLost(e)) return;
3575
        msg(e.message);
3576
      }
Xdev Host Manager authored a week ago
3577
    }
3578

            
Xdev Host Manager authored a week ago
3579
    function renderHosts() {
3580
      const filter = $('filter').value.toLowerCase();
3581
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 5 days ago
3582
        .slice()
3583
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3584
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3585
        .map(h => {
3586
          const problems = state.problems.filter(p => p.host_id === h.id);
3587
          const cls = problems.length ? 'warn' : 'ok';
3588
          return `<tr data-id="${escapeHtml(h.id)}">
3589
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
Bogdan Timofte authored 4 days ago
3590
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 5 days ago
3591
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3592
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3593
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3594
            <td>${escapeHtml(h.status || '')}</td>
3595
          </tr>`;
3596
        }).join('');
Bogdan Timofte authored 4 days ago
3597
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3598
        editHost(button.dataset.edit).catch(e => {
3599
          if (!isAuthLost(e)) msg(e.message);
3600
        });
3601
      }));
Xdev Host Manager authored a week ago
3602
    }
3603

            
Bogdan Timofte authored 5 days ago
3604
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3605
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3606
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3607
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3608
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3609
    }
3610

            
3611
    function vhostRows() {
3612
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3613
        vhost,
3614
        host_id: host.id || '',
3615
        host_fqdn: host.fqdn || '',
3616
        ip: host.ip || '',
3617
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3618
        monitoring: host.monitoring || '',
3619
        status: host.status || '',
3620
      })));
3621
    }
3622

            
3623
    function renderVhosts() {
3624
      const input = $('vhost-filter');
3625
      const filter = input ? input.value.toLowerCase() : '';
3626
      const rows = vhostRows()
3627
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3628
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3629
      $('vhost-stats').innerHTML = [
3630
        ['shown', rows.length],
3631
        ['total', vhostRows().length],
3632
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3633
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3634
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
Bogdan Timofte authored 4 days ago
3635
        <td>
3636
          <div class="vhost-host">
3637
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3638
              ${renderVhostHostOptions(row.host_fqdn)}
3639
            </select>
3640
          </div>
3641
        </td>
Bogdan Timofte authored 4 days ago
3642
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
3643
        <td><div class="vhost-pill-row">${row.derived_aliases.map(name => `<span class="pill derived vhost">${escapeHtml(name)}</span>`).join('')}</div></td>
Bogdan Timofte authored 4 days ago
3644
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3645
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
3646
        <td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
3647
      </tr>`).join('') : '<tr><td colspan="7" class="muted">No vhosts.</td></tr>';
3648
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
3649
        select.addEventListener('change', () => {
3650
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
3651
            if (!isAuthLost(e)) msg(e.message);
3652
            select.value = select.dataset.currentHost || '';
3653
          });
Bogdan Timofte authored 4 days ago
3654
        });
Bogdan Timofte authored 4 days ago
3655
      });
Bogdan Timofte authored 4 days ago
3656
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
3657
        button.addEventListener('click', () => {
3658
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
3659
            if (!isAuthLost(e)) msg(e.message);
3660
          });
3661
        });
3662
      });
3663
    }
3664

            
3665
    function renderVhostEditor() {
3666
      const select = $('vhost-new-host');
3667
      const current = select.value || '';
3668
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3669
    }
3670

            
3671
    function renderVhostHostOptions(selectedHostFqdn) {
3672
      return state.hosts
3673
        .slice()
3674
        .filter(host => (host.status || '') !== 'retired')
3675
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3676
        .map(host => {
3677
          const fqdn = host.fqdn || '';
3678
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
3679
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
3680
        }).join('');
Bogdan Timofte authored 4 days ago
3681
    }
3682

            
3683
    function shortAliasForFqdn(name) {
3684
      const suffix = '.madagascar.xdev.ro';
3685
      name = String(name || '').toLowerCase();
3686
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 5 days ago
3687
    }
3688

            
Bogdan Timofte authored 4 days ago
3689
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
3690
      const vhost = select.dataset.vhostSelect || '';
3691
      const fromHost = select.dataset.currentHost || '';
3692
      const toHost = select.value || '';
3693
      if (!vhost || !toHost || toHost === fromHost) return;
3694
      select.disabled = true;
3695
      try {
3696
        await api('/api/vhosts/reassign', {
3697
          method: 'POST',
3698
          headers: { 'Content-Type': 'application/json' },
3699
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3700
        });
3701
        msg(`vhost ${vhost} moved`);
3702
        await refresh();
3703
      } finally {
3704
        select.disabled = false;
3705
      }
3706
    }
3707

            
Bogdan Timofte authored 4 days ago
3708
    async function addVhostInline() {
3709
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
3710
      const nameInput = $('vhost-new-name');
3711
      const hostSelect = $('vhost-new-host');
3712
      const vhost = (nameInput.value || '').trim().toLowerCase();
3713
      const hostFqdn = hostSelect.value || '';
3714
      if (!vhost || !hostFqdn) return;
3715
      $('vhost-add').disabled = true;
3716
      nameInput.disabled = true;
3717
      hostSelect.disabled = true;
3718
      try {
3719
        await api('/api/vhosts/upsert', {
3720
          method: 'POST',
3721
          headers: { 'Content-Type': 'application/json' },
3722
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
3723
        });
3724
        nameInput.value = '';
3725
        msg(`vhost ${vhost} saved`);
3726
        await refresh();
3727
      } finally {
3728
        $('vhost-add').disabled = false;
3729
        nameInput.disabled = false;
3730
        hostSelect.disabled = false;
3731
      }
3732
    }
3733

            
3734
    async function deleteVhostInline(vhost) {
3735
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
3736
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
3737
      await api('/api/vhosts/delete', {
3738
        method: 'POST',
3739
        headers: { 'Content-Type': 'application/json' },
3740
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
3741
      });
3742
      msg(`vhost ${vhost} deleted`);
3743
      await refresh();
3744
    }
3745

            
Bogdan Timofte authored 4 days ago
3746
    async function editHost(id) {
3747
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
3748
      const host = state.hosts.find(h => h.id === id);
3749
      if (!host) return;
Bogdan Timofte authored 5 days ago
3750
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3751
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3752
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
3753
      hostField('roles').value = (host.roles || []).join(' ');
3754
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 4 days ago
3755
      activateHostForm('Edit host', 'edit', 'fqdn');
Bogdan Timofte authored 5 days ago
3756
    }
3757

            
Bogdan Timofte authored 4 days ago
3758
    async function newHost() {
3759
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 4 days ago
3760
      resetHostForm(false, true);
Bogdan Timofte authored 5 days ago
3761
    }
3762

            
Bogdan Timofte authored 4 days ago
3763
    function activateHostForm(title, mode, focusField = 'id', scroll = true) {
3764
      hostFormMode = mode || 'new';
3765
      $('host-form-title').textContent = title || 'New host';
Bogdan Timofte authored 5 days ago
3766
      hostFormSnapshot = hostFormState();
Bogdan Timofte authored 4 days ago
3767
      syncHostFormActions();
3768
      if (scroll) $('host-form').scrollIntoView({ block: 'start', behavior: 'smooth' });
3769
      hostField(focusField).focus();
Bogdan Timofte authored 5 days ago
3770
    }
3771

            
Bogdan Timofte authored 4 days ago
3772
    function resetHostForm(force = false, scroll = false) {
3773
      if (hostFormBusy && !force) return;
3774
      if (!force && hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3775
      $('host-form').reset();
Bogdan Timofte authored 5 days ago
3776
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3777
      hostField('status').value = 'active';
3778
      hostField('monitoring').value = 'pending';
3779
      activateHostForm('New host', 'new', 'id', scroll);
Bogdan Timofte authored 5 days ago
3780
    }
3781

            
3782
    function hostField(name) {
3783
      return $('host-form').elements.namedItem(name);
3784
    }
3785

            
3786
    function hostFormState() {
3787
      return JSON.stringify(formObject($('host-form')));
3788
    }
3789

            
3790
    function hostFormDirty() {
Bogdan Timofte authored 4 days ago
3791
      return !!hostFormSnapshot && hostFormState() !== hostFormSnapshot;
Bogdan Timofte authored 5 days ago
3792
    }
3793

            
3794
    function setHostFormBusy(busy) {
Bogdan Timofte authored 4 days ago
3795
      hostFormBusy = !!busy;
3796
      syncHostFormActions();
3797
    }
3798

            
3799
    function syncHostFormActions() {
3800
      $('save-host').disabled = hostFormBusy;
3801
      $('delete-host').disabled = hostFormBusy || hostFormMode !== 'edit';
3802
      $('reset-host-form').disabled = hostFormBusy;
Bogdan Timofte authored 5 days ago
3803
    }
3804

            
3805
    function setHostFormMessage(text, isError = false) {
3806
      const message = $('host-form-message');
3807
      message.textContent = text || '';
3808
      message.classList.toggle('error', !!isError);
3809
    }
3810

            
3811
    function clearHostFormMessage() {
3812
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3813
    }
3814

            
3815
    function formObject(form) {
3816
      return Object.fromEntries(new FormData(form).entries());
3817
    }
3818

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

            
Bogdan Timofte authored 6 days ago
3824
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3825

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

            
3831
    if (loginAccount) {
3832
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3833
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3834
      loginAccount.addEventListener('input', () => {
3835
        const value = (loginAccount.value || '').trim();
3836
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3837
      });
3838
    }
3839

            
Xdev Host Manager authored a week ago
3840
    function setOtpDigit(idx, value) {
3841
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 5 days ago
3842
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3843
      otpDigits[idx].classList.toggle('filled', !!digit);
3844
    }
3845

            
Bogdan Timofte authored 4 days ago
3846
    // Move focus to the next empty box: forward from idx, then wrapping to the
3847
    // start. This lets out-of-order entry continue (e.g. after the last box,
3848
    // jump back to the first still-empty box). Stays put when all boxes are full.
3849
    function advanceFocus(idx) {
3850
      for (let i = idx + 1; i < otpDigits.length; i++) {
3851
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3852
      }
3853
      for (let i = 0; i <= idx; i++) {
3854
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3855
      }
3856
    }
3857

            
Bogdan Timofte authored 5 days ago
3858
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3859
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3860
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 5 days ago
3861
      const digits = (text || '').replace(/\D/g, '').split('');
3862
      if (!digits.length) return;
3863
      let last = startIdx;
3864
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3865
        last = startIdx + i;
3866
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3867
      }
Bogdan Timofte authored 5 days ago
3868
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3869
      advanceFocus(last);
Xdev Host Manager authored a week ago
3870
      maybeSubmitOtp();
3871
    }
3872

            
Bogdan Timofte authored 5 days ago
3873
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3874
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3875
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3876
    function maybeSubmitOtp() {
3877
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3878
    }
3879
    function clearOtp() {
Bogdan Timofte authored 5 days ago
3880
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3881
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3882
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3883
      // an unknown operator, so Safari's autofill anchor on the username stays.
3884
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3885
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3886
    }
3887

            
Bogdan Timofte authored 5 days ago
3888
    otpDigits.forEach((input, idx) => {
3889
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3890
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3891
        // A single box may receive several digits at once (autofill / typing fast).
3892
        if (input.value.replace(/\D/g, '').length > 1) {
3893
          fillOtp(input.value, idx);
3894
          return;
3895
        }
3896
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 5 days ago
3897
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3898
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 5 days ago
3899
        maybeSubmitOtp();
3900
      });
Bogdan Timofte authored 5 days ago
3901

            
3902
      input.addEventListener('paste', (e) => {
3903
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3904
        $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3905
        const text = (e.clipboardData || window.clipboardData).getData('text');
3906
        fillOtp(text, idx);
Bogdan Timofte authored 5 days ago
3907
      });
Bogdan Timofte authored 5 days ago
3908

            
3909
      input.addEventListener('keydown', (e) => {
3910
        if (e.key === 'Backspace') {
3911
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3912
          $('login-error').textContent = '';
Bogdan Timofte authored 5 days ago
3913
          if (input.value) { setOtpDigit(idx, ''); }
3914
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3915
          syncOtpFields();
3916
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3917
          e.preventDefault();
3918
          otpDigits[idx - 1].focus();
3919
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3920
          e.preventDefault();
3921
          otpDigits[idx + 1].focus();
3922
        }
3923
      });
3924
    });
3925

            
Bogdan Timofte authored 4 days ago
3926
    // Focus the first OTP box only for a returning operator (username known).
3927
    // For an unknown operator, leave focus on the username field so Safari can
3928
    // present its OTP autofill anchored there without being dismissed by a focus
3929
    // change (pbx-admin pattern).
3930
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3931
    else if (loginAccount) loginAccount.focus();
3932
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3933

            
Bogdan Timofte authored 5 days ago
3934
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
3935
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
3936
        event.preventDefault();
Bogdan Timofte authored 4 days ago
3937
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
3938
        showPage(link.dataset.pageLink, true);
3939
      });
3940
    });
3941

            
Bogdan Timofte authored 4 days ago
3942
    window.addEventListener('popstate', () => {
3943
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3944
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3945
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3946
    });
Bogdan Timofte authored 5 days ago
3947

            
Bogdan Timofte authored 4 days ago
3948
    async function copyText(text) {
3949
      if (navigator.clipboard && window.isSecureContext) {
3950
        await navigator.clipboard.writeText(text);
3951
        return;
3952
      }
3953
      const input = document.createElement('textarea');
3954
      input.value = text;
3955
      input.setAttribute('readonly', '');
3956
      input.style.position = 'fixed';
3957
      input.style.left = '-10000px';
3958
      document.body.appendChild(input);
3959
      input.select();
3960
      document.execCommand('copy');
3961
      document.body.removeChild(input);
3962
    }
3963

            
3964
    $('copy-build').addEventListener('click', async () => {
3965
      try {
3966
        await copyText($('copy-build').dataset.buildDetails || '');
3967
        if (state.authenticated) msg('build details copied');
3968
      } catch (e) {
3969
        if (state.authenticated) msg('copy failed');
3970
      }
3971
    });
3972

            
Xdev Host Manager authored a week ago
3973
    $('login-form').addEventListener('submit', async (event) => {
3974
      event.preventDefault();
Bogdan Timofte authored 5 days ago
3975
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
3976
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
3977
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
3978
      try {
Xdev Host Manager authored a week ago
3979
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
3980
        await refresh();
Xdev Host Manager authored a week ago
3981
      } catch (e) {
3982
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
3983
      } finally {
Xdev Host Manager authored a week ago
3984
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
3985
      }
Xdev Host Manager authored a week ago
3986
    });
3987

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

            
Bogdan Timofte authored 4 days ago
3993
    $('refresh').addEventListener('click', () => refresh().catch(e => {
3994
      if (!isAuthLost(e)) msg(e.message);
3995
    }));
Xdev Host Manager authored a week ago
3996
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
3997
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
3998
    $('vhost-add').addEventListener('click', () => {
3999
      addVhostInline().catch(e => {
4000
        if (!isAuthLost(e)) msg(e.message);
4001
      });
4002
    });
4003
    $('vhost-new-name').addEventListener('keydown', (event) => {
4004
      if (event.key !== 'Enter') return;
4005
      event.preventDefault();
4006
      addVhostInline().catch(e => {
4007
        if (!isAuthLost(e)) msg(e.message);
4008
      });
4009
    });
Bogdan Timofte authored 4 days ago
4010
    $('new-host').addEventListener('click', () => {
4011
      newHost().catch(e => {
4012
        if (!isAuthLost(e)) msg(e.message);
4013
      });
4014
    });
Bogdan Timofte authored 4 days ago
4015
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4016
      if (!isAuthLost(e)) msg(e.message);
4017
    }));
Bogdan Timofte authored 4 days ago
4018
    $('reset-host-form').addEventListener('click', () => resetHostForm());
Xdev Host Manager authored a week ago
4019

            
Xdev Host Manager authored a week ago
4020
    $('host-form').addEventListener('submit', async (event) => {
4021
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4022
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4023
      setHostFormBusy(true);
4024
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4025
      try {
Bogdan Timofte authored 4 days ago
4026
        const savedId = hostField('id').value;
Xdev Host Manager authored a week ago
4027
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
4028
        msg('host saved');
4029
        await refresh();
Bogdan Timofte authored 4 days ago
4030
        const host = state.hosts.find(entry => entry.id === savedId);
4031
        if (host) {
4032
          clearHostFormMessage();
4033
          for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
4034
          hostField('aliases').value = (host.aliases || []).join('\n');
4035
          hostField('roles').value = (host.roles || []).join(' ');
4036
          hostField('sources').value = (host.sources || []).join(' ');
4037
          activateHostForm('Edit host', 'edit', 'fqdn', false);
4038
        } else {
4039
          resetHostForm(true, false);
4040
        }
Bogdan Timofte authored 5 days ago
4041
      } catch (e) {
Bogdan Timofte authored 4 days ago
4042
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4043
        setHostFormMessage(e.message, true);
4044
        msg(e.message);
4045
      } finally {
4046
        setHostFormBusy(false);
4047
      }
4048
    });
4049

            
4050
    $('host-form').addEventListener('invalid', (event) => {
4051
      setHostFormMessage('Complete the required host fields before saving.', true);
4052
    }, true);
4053

            
4054
    $('host-form').addEventListener('input', () => {
4055
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4056
    });
4057

            
4058
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4059
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4060
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4061
      setHostFormBusy(true);
4062
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4063
      try {
4064
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4065
        msg('host deleted');
4066
        await refresh();
Bogdan Timofte authored 4 days ago
4067
        resetHostForm(true, false);
Bogdan Timofte authored 5 days ago
4068
      } catch (e) {
Bogdan Timofte authored 4 days ago
4069
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4070
        setHostFormMessage(e.message, true);
4071
        msg(e.message);
4072
      } finally {
4073
        setHostFormBusy(false);
4074
      }
Xdev Host Manager authored a week ago
4075
    });
4076

            
Bogdan Timofte authored 4 days ago
4077
    resetHostForm(true, false);
4078

            
Xdev Host Manager authored a week ago
4079
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4080
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4081
      try {
4082
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4083
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4084
      } catch (e) {
4085
        if (!isAuthLost(e)) msg(e.message);
4086
      }
Xdev Host Manager authored a week ago
4087
    });
4088

            
Bogdan Timofte authored 4 days ago
4089
    refresh().catch(e => {
4090
      if (!isAuthLost(e)) showLogin(e.message);
4091
    });
Xdev Host Manager authored a week ago
4092
  </script>
4093
</body>
4094
</html>
4095
HTML
Bogdan Timofte authored 6 days ago
4096
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4097
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4098
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4099
    return $html;
Xdev Host Manager authored a week ago
4100
}