LocalAuthority / scripts / host_manager.pl
Newer Older
4130 lines | 159.876kb
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 4 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 4 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 4 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 4 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 4 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 4 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 5 days ago
2856
    .modal-backdrop {
2857
      position: fixed;
2858
      inset: 0;
2859
      z-index: 10;
2860
      display: grid;
2861
      align-items: start;
2862
      justify-items: center;
2863
      padding: 72px 16px 24px;
2864
      background: rgba(21,32,51,.48);
2865
      overflow: auto;
2866
    }
2867
    .modal-backdrop[hidden] { display: none; }
2868
    .modal {
2869
      width: min(840px, 100%);
2870
      max-height: calc(100dvh - 96px);
2871
      overflow: auto;
2872
      background: var(--panel);
2873
      border: 1px solid var(--line);
2874
      border-radius: 8px;
2875
      box-shadow: 0 20px 60px rgba(21,32,51,.26);
2876
    }
2877
    .modal-head {
2878
      position: sticky;
2879
      top: 0;
2880
      z-index: 1;
2881
      display: flex;
2882
      align-items: center;
2883
      justify-content: space-between;
2884
      gap: 12px;
2885
      padding: 12px 14px;
2886
      border-bottom: 1px solid var(--line);
2887
      background: #fafbfc;
2888
    }
2889
    .modal-head h2 { margin: 0; font-size: 14px; }
2890
    .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
Bogdan Timofte authored 5 days ago
2891
    .form-message { min-height: 18px; color: var(--muted); font-size: 13px; }
2892
    .form-message.error { color: var(--bad); }
Bogdan Timofte authored 5 days ago
2893
    .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
Xdev Host Manager authored a week ago
2894
    @media (max-width: 760px) {
Bogdan Timofte authored 5 days ago
2895
      header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
2896
      .header-right { justify-content: flex-start; flex-wrap: wrap; }
2897
      #message { max-width: 100%; }
2898
      .panel-head { align-items: stretch; flex-direction: column; }
2899
      .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2900
      .host-tools input { max-width: none; }
Bogdan Timofte authored 4 days ago
2901
      .vhost-inline-editor { grid-template-columns: 1fr; }
Bogdan Timofte authored 4 days ago
2902
      .debug-controls { align-items: stretch; }
Bogdan Timofte authored 5 days ago
2903
      .modal-backdrop { padding-top: 16px; }
2904
      .modal { max-height: calc(100dvh - 32px); }
Xdev Host Manager authored a week ago
2905
      .grid { grid-template-columns: 1fr; }
2906
      table { min-width: 760px; }
2907
      .table-wrap { overflow-x: auto; }
2908
    }
2909
  </style>
2910
</head>
Bogdan Timofte authored 6 days ago
2911
<body class="is-login">
Xdev Host Manager authored a week ago
2912

            
Xdev Host Manager authored a week ago
2913
  <!-- ── Login screen ── -->
2914
  <div id="login-screen">
2915
    <div class="login-card">
2916
      <div class="brand">
2917
        <div class="icon">
Xdev Host Manager authored a week ago
2918
          <svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
2919
            <rect x="16" y="10" width="32" height="44" rx="4"/>
2920
            <rect x="21" y="16" width="22" height="8" rx="2"/>
2921
            <rect x="21" y="28" width="22" height="8" rx="2"/>
2922
            <rect x="21" y="40" width="22" height="8" rx="2"/>
2923
            <path d="M26 20h8M26 32h8M26 44h8"/>
2924
            <path d="M40 20h.01M40 32h.01M40 44h.01"/>
Xdev Host Manager authored a week ago
2925
          </svg>
2926
        </div>
Xdev Host Manager authored a week ago
2927
        <h1>Madagascar Local Authority</h1>
2928
        <p>Hosts, DNS &amp; Local CA</p>
Xdev Host Manager authored a week ago
2929
      </div>
Bogdan Timofte authored 4 days ago
2930
      <div id="login-error"></div>
Bogdan Timofte authored 6 days ago
2931
      <form id="login-form" method="post" action="/api/login" autocomplete="on" novalidate>
2932
        <div class="pm-helper-fields" aria-hidden="true">
2933
          <input type="text" id="login-account" name="username" autocomplete="username" autocapitalize="off" spellcheck="false">
2934
          <input type="hidden" id="otp-hidden" name="otp">
2935
        </div>
Xdev Host Manager authored a week ago
2936
        <div class="otp-row">
Bogdan Timofte authored 4 days ago
2937
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 1">
2938
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 2">
2939
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 3">
2940
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 4">
2941
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 5">
2942
          <input type="text" class="otp-digit" maxlength="1" inputmode="numeric" autocomplete="off" required aria-label="Digit 6">
Xdev Host Manager authored a week ago
2943
        </div>
2944
      </form>
2945
    </div>
2946
  </div>
2947

            
2948
  <!-- ── App (shown after login) ── -->
2949
  <div id="app">
2950
    <header>
Xdev Host Manager authored a week ago
2951
      <h1>Madagascar Local Authority</h1>
Bogdan Timofte authored 5 days ago
2952
      <nav aria-label="Sections">
2953
        <a href="/overview" data-page-link="overview">Overview</a>
2954
        <a href="/hosts" data-page-link="hosts">Hosts</a>
Bogdan Timofte authored 4 days ago
2955
        <a href="/vhosts" data-page-link="vhosts">Vhosts</a>
Bogdan Timofte authored 5 days ago
2956
        <a href="/dns" data-page-link="dns">DNS</a>
2957
        <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2958
        <a href="/ca" data-page-link="ca">Local CA</a>
Bogdan Timofte authored 4 days ago
2959
        <a href="/debug" data-page-link="debug">Debug</a>
Bogdan Timofte authored 5 days ago
2960
      </nav>
Xdev Host Manager authored a week ago
2961
      <div class="header-right">
2962
        <span class="muted" id="app-updated"></span>
Bogdan Timofte authored 5 days ago
2963
        <span id="message" class="muted"></span>
2964
        <button id="refresh">Refresh</button>
Xdev Host Manager authored a week ago
2965
        <button type="button" id="logout">Logout</button>
Xdev Host Manager authored a week ago
2966
      </div>
Xdev Host Manager authored a week ago
2967
    </header>
2968
    <main>
Bogdan Timofte authored 5 days ago
2969
      <section class="page" id="page-overview" data-page="overview">
2970
        <section class="panel">
2971
          <div class="panel-head">
2972
            <h2>Overview</h2>
2973
            <div class="stats" id="stats"></div>
2974
          </div>
2975
          <div class="problems" id="problems"></div>
2976
        </section>
Xdev Host Manager authored a week ago
2977
      </section>
2978

            
Bogdan Timofte authored 5 days ago
2979
      <section class="page" id="page-hosts" data-page="hosts" hidden>
2980
        <section class="panel">
2981
          <div class="panel-head">
2982
            <h2>Hosts</h2>
2983
            <div class="host-tools">
2984
              <input id="filter" placeholder="filter">
2985
              <button type="button" id="new-host">New host</button>
2986
            </div>
2987
          </div>
2988
          <div class="table-wrap">
2989
            <table>
2990
              <thead>
2991
                <tr>
2992
                  <th style="width: 120px">ID</th>
Bogdan Timofte authored 4 days ago
2993
                  <th style="width: 140px">IP</th>
Bogdan Timofte authored 5 days ago
2994
                  <th>Names</th>
2995
                  <th style="width: 150px">Roles</th>
2996
                  <th style="width: 110px">Monitoring</th>
2997
                  <th style="width: 90px">Status</th>
2998
                </tr>
2999
              </thead>
3000
              <tbody id="hosts"></tbody>
3001
            </table>
3002
          </div>
3003
        </section>
Xdev Host Manager authored a week ago
3004
      </section>
Xdev Host Manager authored a week ago
3005

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

            
Bogdan Timofte authored 5 days ago
3039
      <section class="page" id="page-dns" data-page="dns" hidden>
3040
        <section class="toolbar">
3041
          <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a>
3042
          <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a>
3043
          <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a>
3044
          <button id="write-tsv">Write local-hosts.tsv</button>
3045
        </section>
Xdev Host Manager authored a week ago
3046
      </section>
3047

            
Bogdan Timofte authored 5 days ago
3048
      <section class="page" id="page-work-orders" data-page="work-orders" hidden>
3049
        <section class="panel">
3050
          <div class="panel-head">
3051
            <h2>Work Orders</h2>
3052
            <div class="stats" id="wo-stats"></div>
3053
          </div>
3054
          <div class="problems" id="work-orders"></div>
3055
        </section>
Xdev Host Manager authored a week ago
3056
      </section>
3057

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

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

            
Bogdan Timofte authored 5 days ago
3139
    <div id="host-modal" class="modal-backdrop" hidden>
3140
      <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title">
3141
        <div class="modal-head">
3142
          <h2 id="host-modal-title">Edit host</h2>
3143
          <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button>
Xdev Host Manager authored a week ago
3144
        </div>
3145
        <form id="host-form" class="grid">
3146
          <label>ID<input name="id" required></label>
Bogdan Timofte authored 4 days ago
3147
          <label>FQDN<input name="fqdn" required></label>
Xdev Host Manager authored a week ago
3148
          <label>Status<select name="status"><option>active</option><option>planned</option><option>retired</option></select></label>
Bogdan Timofte authored 4 days ago
3149
          <label>IP<input name="ip" required></label>
3150
          <label class="span2">Aliases<textarea name="aliases"></textarea></label>
Xdev Host Manager authored a week ago
3151
          <label>Roles<input name="roles"></label>
3152
          <label>Sources<input name="sources"></label>
3153
          <label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label>
3154
          <label>Notes<input name="notes"></label>
Bogdan Timofte authored 5 days ago
3155
          <div id="host-form-message" class="span2 form-message" aria-live="polite"></div>
Bogdan Timofte authored 5 days ago
3156
          <div class="span2 form-actions">
Bogdan Timofte authored 5 days ago
3157
            <button class="primary" type="submit" id="save-host">Save host</button>
Xdev Host Manager authored a week ago
3158
            <button class="danger" type="button" id="delete-host">Delete host</button>
3159
          </div>
3160
        </form>
3161
      </section>
Bogdan Timofte authored 5 days ago
3162
    </div>
Xdev Host Manager authored a week ago
3163
  </div>
3164

            
Bogdan Timofte authored 4 days ago
3165
  <div class="build-control" title="Running build __HOST_MANAGER_BUILD_TITLE__">
3166
    <span class="build-badge">__HOST_MANAGER_BUILD__</span>
3167
    <button type="button" class="build-copy" id="copy-build" data-build-details="__HOST_MANAGER_BUILD_DETAILS__" aria-label="Copy build details">Copy</button>
3168
  </div>
Bogdan Timofte authored 6 days ago
3169

            
Xdev Host Manager authored a week ago
3170
  <script>
Bogdan Timofte authored 4 days ago
3171
    let state = { hosts: [], problems: [], workOrders: [], authenticated: false, debugTable: '' };
Bogdan Timofte authored 5 days ago
3172
    let hostFormSnapshot = '';
Xdev Host Manager authored a week ago
3173

            
3174
    const $ = (id) => document.getElementById(id);
3175
    const msg = (text) => { $('message').textContent = text || ''; };
Bogdan Timofte authored 5 days ago
3176
    const PAGE_PATHS = {
3177
      '/': 'overview',
3178
      '/overview': 'overview',
3179
      '/hosts': 'hosts',
Bogdan Timofte authored 4 days ago
3180
      '/vhosts': 'vhosts',
Bogdan Timofte authored 5 days ago
3181
      '/dns': 'dns',
3182
      '/work-orders': 'work-orders',
3183
      '/ca': 'ca',
Bogdan Timofte authored 4 days ago
3184
      '/debug': 'debug',
Bogdan Timofte authored 5 days ago
3185
    };
Xdev Host Manager authored a week ago
3186

            
Bogdan Timofte authored 4 days ago
3187
    function isAuthLost(error) {
3188
      return !!(error && error.authLost);
3189
    }
3190

            
3191
    function authLostError(message) {
3192
      const error = new Error(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3193
      error.authLost = true;
3194
      return error;
3195
    }
3196

            
3197
    function handleAuthLost(message) {
3198
      state.authenticated = false;
3199
      msg('');
3200
      showLogin(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3201
    }
3202

            
Bogdan Timofte authored 4 days ago
3203
    async function ensureAuthenticated(message) {
3204
      if (!state.authenticated) {
3205
        handleAuthLost(message || 'Autentifica-te pentru a continua.');
3206
        return false;
3207
      }
3208
      const session = await api('/api/session');
3209
      state.authenticated = session.authenticated;
3210
      if (!state.authenticated) {
3211
        handleAuthLost(message || 'Sesiunea a expirat. Autentifica-te din nou.');
3212
        return false;
3213
      }
3214
      return true;
3215
    }
3216

            
Xdev Host Manager authored a week ago
3217
    async function api(path, options = {}) {
3218
      const res = await fetch(path, options);
Bogdan Timofte authored 4 days ago
3219
      let body = {};
3220
      try {
3221
        body = await res.json();
3222
      } catch (_) {
3223
        body = {};
3224
      }
3225
      const errorCode = body.error || '';
3226
      if (!res.ok) {
3227
        if (res.status === 401 && !(path === '/api/login' && errorCode === 'invalid_otp')) {
3228
          const error = authLostError();
3229
          handleAuthLost(error.message);
3230
          throw error;
3231
        }
3232
        throw new Error(errorCode || res.statusText);
3233
      }
Xdev Host Manager authored a week ago
3234
      return body;
3235
    }
3236

            
Bogdan Timofte authored 5 days ago
3237
    function currentPage() {
3238
      return PAGE_PATHS[window.location.pathname] || 'overview';
3239
    }
3240

            
3241
    function showPage(page, push = false) {
3242
      const target = page || 'overview';
3243
      document.querySelectorAll('[data-page]').forEach(section => {
3244
        section.hidden = section.dataset.page !== target;
3245
      });
3246
      document.querySelectorAll('[data-page-link]').forEach(link => {
3247
        link.classList.toggle('active', link.dataset.pageLink === target);
3248
        link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
3249
      });
3250
      if (push) {
3251
        const href = target === 'overview' ? '/overview' : '/' + target;
3252
        history.pushState({ page: target }, '', href);
3253
      }
Bogdan Timofte authored 4 days ago
3254
      if (state.authenticated && target === 'debug') {
Bogdan Timofte authored 4 days ago
3255
        renderDebugDatabase().catch(e => {
3256
          if (!isAuthLost(e)) msg(e.message);
3257
        });
Bogdan Timofte authored 4 days ago
3258
      }
Bogdan Timofte authored 5 days ago
3259
    }
3260

            
Xdev Host Manager authored a week ago
3261
    function showLogin(errorText) {
Bogdan Timofte authored 4 days ago
3262
      state.authenticated = false;
Bogdan Timofte authored 6 days ago
3263
      document.body.classList.remove('is-app');
3264
      document.body.classList.add('is-login');
Xdev Host Manager authored a week ago
3265
      $('app').style.display = 'none';
3266
      $('login-screen').style.display = 'flex';
3267
      $('login-error').textContent = errorText || '';
Bogdan Timofte authored 6 days ago
3268
      clearOtp();
Xdev Host Manager authored a week ago
3269
    }
3270

            
3271
    function showApp() {
Bogdan Timofte authored 6 days ago
3272
      document.body.classList.remove('is-login');
3273
      document.body.classList.add('is-app');
Xdev Host Manager authored a week ago
3274
      $('login-screen').style.display = 'none';
3275
      $('app').style.display = 'block';
Bogdan Timofte authored 5 days ago
3276
      showPage(currentPage());
Xdev Host Manager authored a week ago
3277
    }
3278

            
Xdev Host Manager authored a week ago
3279
    async function refresh() {
3280
      const session = await api('/api/session');
3281
      state.authenticated = session.authenticated;
Bogdan Timofte authored 4 days ago
3282
      if (!state.authenticated) { showLogin('Autentifica-te pentru a continua.'); return; }
Xdev Host Manager authored a week ago
3283
      showApp();
Xdev Host Manager authored a week ago
3284
      const data = await api('/api/hosts');
3285
      state.hosts = data.hosts || [];
3286
      state.problems = data.problems || [];
3287
      render(data);
Xdev Host Manager authored a week ago
3288
      await renderCa();
Xdev Host Manager authored a week ago
3289
      await renderWorkOrders();
Bogdan Timofte authored 4 days ago
3290
      if (currentPage() === 'debug') await renderDebugDatabase();
Xdev Host Manager authored a week ago
3291
    }
3292

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

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

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

            
3306
      renderHosts();
Bogdan Timofte authored 4 days ago
3307
      renderVhostEditor();
Bogdan Timofte authored 4 days ago
3308
      renderVhosts();
Xdev Host Manager authored a week ago
3309
    }
3310

            
Xdev Host Manager authored a week ago
3311
    async function renderCa() {
3312
      try {
3313
        const status = await api('/api/ca/status');
3314
        if (!status.initialized) {
3315
          $('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
3316
          $('ca-certs-summary').innerHTML = '';
3317
          $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
Xdev Host Manager authored a week ago
3318
          return;
3319
        }
3320
        const certs = await api('/api/ca/certificates');
Bogdan Timofte authored 5 days ago
3321
        const caDays = daysUntil(status.not_after);
Xdev Host Manager authored a week ago
3322
        $('ca-status').innerHTML = `
Bogdan Timofte authored 5 days ago
3323
          <div class="muted ca-detail">
Xdev Host Manager authored a week ago
3324
            <div><strong>${escapeHtml(status.subject || '')}</strong></div>
Bogdan Timofte authored 5 days ago
3325
            <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
Xdev Host Manager authored a week ago
3326
            <div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
Bogdan Timofte authored 5 days ago
3327
            <div>
3328
              <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
3329
              <span>${certs.length} issued certificate(s)</span>
3330
            </div>
Xdev Host Manager authored a week ago
3331
          </div>`;
Bogdan Timofte authored 5 days ago
3332
        $('ca-certs-summary').innerHTML = [
3333
          ['issued', certs.length],
3334
          ['expiring', certs.filter(cert => {
3335
            const days = daysUntil(cert.not_after);
3336
            return days !== null && days >= 0 && days <= 30;
3337
          }).length],
3338
          ['expired', certs.filter(cert => {
3339
            const days = daysUntil(cert.not_after);
3340
            return days !== null && days < 0;
3341
          }).length],
3342
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3343
        $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
3344
          const days = daysUntil(cert.not_after);
3345
          const dnsNames = cert.dns_names || [];
3346
          const dnsHtml = dnsNames.length
3347
            ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
3348
            : '<span class="muted">No DNS SANs reported.</span>';
3349
          return `<tr>
3350
            <td><strong>${escapeHtml(cert.name || '')}</strong></td>
3351
            <td>${dnsHtml}</td>
3352
            <td>
3353
              <div class="ca-detail">
3354
                <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
3355
                <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
3356
              </div>
3357
            </td>
3358
            <td class="mono">${escapeHtml(cert.serial || '')}</td>
3359
            <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
3360
            <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
3361
          </tr>`;
3362
        }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
Xdev Host Manager authored a week ago
3363
      } catch (e) {
Bogdan Timofte authored 4 days ago
3364
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3365
        $('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
Bogdan Timofte authored 5 days ago
3366
        $('ca-certs-summary').innerHTML = '';
3367
        $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
Xdev Host Manager authored a week ago
3368
      }
3369
    }
3370

            
Bogdan Timofte authored 5 days ago
3371
    function daysUntil(dateText) {
3372
      const time = Date.parse(dateText || '');
3373
      if (!Number.isFinite(time)) return null;
3374
      return Math.ceil((time - Date.now()) / 86400000);
3375
    }
3376

            
3377
    function certStatusClass(days) {
3378
      if (days === null) return '';
3379
      if (days < 0) return 'bad';
3380
      if (days <= 30) return 'warn';
3381
      return 'ok';
3382
    }
3383

            
3384
    function certStatusLabel(days) {
3385
      if (days === null) return 'validity unknown';
3386
      if (days < 0) return 'expired';
3387
      if (days === 0) return 'expires today';
3388
      return `${days}d remaining`;
3389
    }
3390

            
Xdev Host Manager authored a week ago
3391
    async function renderWorkOrders() {
3392
      try {
3393
        const data = await api('/api/work-orders');
3394
        state.workOrders = data.work_orders || [];
3395
        $('wo-stats').innerHTML = [
3396
          ['pending', data.counts.pending],
3397
          ['total', data.counts.work_orders],
3398
        ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
3399

            
3400
        if (!state.workOrders.length) {
3401
          $('work-orders').innerHTML = '<div class="muted" style="padding: 8px 0">No work orders.</div>';
3402
          return;
3403
        }
3404

            
3405
        $('work-orders').innerHTML = state.workOrders.map(wo => {
Xdev Host Manager authored a week ago
3406
          const checklist = wo.checklist || [];
3407
          const doneItems = checklist.filter(item => (item.status || 'pending') === 'done').length;
3408
          const checklistComplete = checklist.length === 0 || doneItems === checklist.length;
3409
          const checklistHtml = checklist.map(item => {
3410
            const checked = (item.status || 'pending') === 'done' ? 'checked' : '';
Bogdan Timofte authored 6 days ago
3411
            return `<label class="work-order-checkitem">
Xdev Host Manager authored a week ago
3412
              <input type="checkbox" data-wo-checklist="${escapeHtml(wo.id)}" data-item-id="${escapeHtml(item.id || '')}" ${checked}>
3413
              <span><strong>${escapeHtml(item.id || '')}</strong> ${escapeHtml(item.text || '')}</span>
3414
            </label>`;
3415
          }).join('');
Xdev Host Manager authored a week ago
3416
          const actions = (wo.actions || []).map(a => {
3417
            const target = [a.host_id, a.name].filter(Boolean).join(' ');
3418
            return `<div><span class="pill">${escapeHtml(a.type || '')}</span> ${escapeHtml(target)}</div>`;
3419
          }).join('');
3420
          const statusClass = (wo.status || 'pending') === 'pending' ? 'warn' : 'ok';
3421
          const button = (wo.status || 'pending') === 'pending'
Xdev Host Manager authored a week ago
3422
            ? `<button type="button" class="primary" data-confirm-wo="${escapeHtml(wo.id)}" ${checklistComplete ? '' : 'disabled'}>Confirm</button>`
Xdev Host Manager authored a week ago
3423
            : '';
Bogdan Timofte authored 6 days ago
3424
          return `<div class="problem work-order-card">
3425
            <div class="work-order-head">
Xdev Host Manager authored a week ago
3426
              <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
3427
              ${button}
3428
            </div>
Bogdan Timofte authored 6 days ago
3429
            <div class="work-order-title">${escapeHtml(wo.title || '')}</div>
Xdev Host Manager authored a week ago
3430
            <div class="muted">${escapeHtml(wo.reason || '')}</div>
Bogdan Timofte authored 6 days ago
3431
            <div class="work-order-checklist">${checklistHtml}</div>
3432
            <div class="work-order-actions">${actions}</div>
Xdev Host Manager authored a week ago
3433
            ${wo.confirmed_at ? `<div class="muted">confirmed ${escapeHtml(wo.confirmed_at)}</div>` : ''}
3434
          </div>`;
3435
        }).join('');
Xdev Host Manager authored a week ago
3436
        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
3437
        document.querySelectorAll('[data-confirm-wo]').forEach(button => button.addEventListener('click', () => confirmWorkOrder(button.dataset.confirmWo)));
3438
      } catch (e) {
Bogdan Timofte authored 4 days ago
3439
        if (isAuthLost(e)) return;
Xdev Host Manager authored a week ago
3440
        $('work-orders').innerHTML = `<div class="problem"><strong>Work orders unavailable</strong> ${escapeHtml(e.message)}</div>`;
3441
      }
3442
    }
3443

            
Bogdan Timofte authored 4 days ago
3444
    async function renderDebugDatabase() {
3445
      if (!state.authenticated) return;
3446
      const data = await api('/api/debug/database/tables');
3447
      const tables = data.tables || [];
Bogdan Timofte authored 4 days ago
3448
      const selected = tables.some(table => table.name === state.debugTable) ? state.debugTable : (tables[0] ? tables[0].name : '');
3449
      state.debugTable = selected;
Bogdan Timofte authored 4 days ago
3450
      $('debug-db-stats').innerHTML = [
3451
        ['tables', data.counts ? data.counts.tables : tables.length],
3452
        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
3453
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3454
      $('debug-db-meta').textContent = data.database || '';
Bogdan Timofte authored 4 days ago
3455
      renderDebugTableCards(tables, selected, data.database || '');
Bogdan Timofte authored 4 days ago
3456
      if (selected) {
3457
        await renderDebugTable(selected);
3458
      } else {
3459
        clearDebugTable();
3460
      }
3461
    }
3462

            
Bogdan Timofte authored 4 days ago
3463
    function renderDebugTableCards(tables, selected, database) {
Bogdan Timofte authored 4 days ago
3464
      $('debug-db-tables').innerHTML = tables.length
3465
        ? tables.map(table => {
3466
            const active = table.name === selected;
Bogdan Timofte authored 4 days ago
3467
            const ref = debugTableReference(database, table.name);
3468
            return `<div class="debug-table-card ${active ? 'active' : ''}">
3469
              <button type="button" class="debug-table-card-main" data-debug-table="${escapeHtml(table.name)}" aria-pressed="${active ? 'true' : 'false'}">
3470
                <span class="debug-table-card-name mono">${escapeHtml(table.name)}</span>
3471
                <span class="debug-table-card-rows">${escapeHtml(String(table.rows || 0))} rows</span>
3472
              </button>
Bogdan Timofte authored 4 days ago
3473
              <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
3474
            </div>`;
Bogdan Timofte authored 4 days ago
3475
          }).join('')
3476
        : '<div class="ca-empty muted">No database tables found.</div>';
3477
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3478
        button.addEventListener('click', () => selectDebugTable(button.dataset.debugTable).catch(e => {
3479
          if (!isAuthLost(e)) msg(e.message);
3480
        }));
3481
      });
Bogdan Timofte authored 4 days ago
3482
      document.querySelectorAll('[data-debug-table-ref]').forEach(button => {
3483
        button.addEventListener('click', async () => {
3484
          try {
3485
            await copyText(button.dataset.debugTableRef || '');
3486
            msg('table reference copied');
3487
          } catch (e) {
3488
            msg('copy failed');
3489
          }
3490
        });
3491
      });
3492
    }
3493

            
3494
    function debugTableReference(database, tableName) {
3495
      return `sqlite:${database || ''}#${tableName || ''}`;
Bogdan Timofte authored 4 days ago
3496
    }
3497

            
3498
    async function selectDebugTable(tableName) {
3499
      state.debugTable = tableName || '';
3500
      document.querySelectorAll('[data-debug-table]').forEach(button => {
3501
        const active = button.dataset.debugTable === state.debugTable;
Bogdan Timofte authored 4 days ago
3502
        const card = button.closest('.debug-table-card');
3503
        if (card) card.classList.toggle('active', active);
Bogdan Timofte authored 4 days ago
3504
        button.setAttribute('aria-pressed', active ? 'true' : 'false');
3505
      });
3506
      if (state.debugTable) await renderDebugTable(state.debugTable);
3507
    }
3508

            
3509
    function clearDebugTable() {
3510
      $('debug-table-stats').innerHTML = '';
Bogdan Timofte authored 4 days ago
3511
      updateDebugExportLinks('');
Bogdan Timofte authored 4 days ago
3512
      $('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3513
      $('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3514
      $('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
3515
      $('debug-table-foreign-keys').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
Bogdan Timofte authored 4 days ago
3516
    }
3517

            
3518
    async function renderDebugTable(tableName) {
3519
      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
3520
      if (data.error) throw new Error(data.error);
3521
      $('debug-table-stats').innerHTML = [
3522
        ['table', data.table || tableName],
3523
        ['rows', data.row_count || 0],
3524
        ['shown', (data.rows || []).length],
3525
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
Bogdan Timofte authored 4 days ago
3526
      updateDebugExportLinks(data.table || tableName);
Bogdan Timofte authored 4 days ago
3527
      renderDebugRows(data);
3528
      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
3529
      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
3530
      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
3531
    }
3532

            
Bogdan Timofte authored 4 days ago
3533
    function updateDebugExportLinks(tableName) {
3534
      const encoded = encodeURIComponent(tableName || '');
3535
      [
3536
        ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
3537
        ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
3538
      ].forEach(([id, href]) => {
3539
        const link = $(id);
3540
        const enabled = !!tableName;
3541
        link.href = enabled ? href : '#';
3542
        link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
3543
      });
3544
    }
3545

            
Bogdan Timofte authored 4 days ago
3546
    function renderDebugRows(data) {
3547
      const rows = data.rows || [];
3548
      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3549
      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3550
    }
3551

            
3552
    function renderDebugObjectTable(rows, preferredKeys) {
3553
      const keys = preferredKeys && preferredKeys.length
3554
        ? preferredKeys
3555
        : Array.from(rows.reduce((set, row) => {
3556
            Object.keys(row || {}).forEach(key => set.add(key));
3557
            return set;
3558
          }, new Set()));
3559
      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3560
      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3561
      const body = rows.length
3562
        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3563
        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3564
      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3565
    }
3566

            
3567
    function debugCell(value) {
3568
      if (value === null || value === undefined) return 'NULL';
3569
      if (Array.isArray(value)) return value.join(', ');
3570
      if (typeof value === 'object') return JSON.stringify(value);
3571
      return String(value);
3572
    }
3573

            
Xdev Host Manager authored a week ago
3574
    async function updateWorkOrderChecklist(id, itemId, checked) {
3575
      try {
3576
        await api('/api/work-orders/checklist', {
3577
          method: 'POST',
3578
          headers: { 'Content-Type': 'application/json' },
3579
          body: JSON.stringify({ id, item_id: itemId, status: checked ? 'done' : 'pending' })
3580
        });
3581
        msg('work order updated');
3582
        await refresh();
Bogdan Timofte authored 4 days ago
3583
      } catch (e) {
3584
        if (isAuthLost(e)) return;
3585
        msg(e.message);
3586
        await refresh().catch(refreshError => {
3587
          if (!isAuthLost(refreshError)) msg(refreshError.message);
3588
        });
3589
      }
Xdev Host Manager authored a week ago
3590
    }
3591

            
Xdev Host Manager authored a week ago
3592
    async function confirmWorkOrder(id) {
3593
      const typed = prompt(`Type ${id} to confirm this work order`);
3594
      if (typed !== id) return;
3595
      try {
3596
        await api('/api/work-orders/confirm', {
3597
          method: 'POST',
3598
          headers: { 'Content-Type': 'application/json' },
3599
          body: JSON.stringify({ id, confirm: typed })
3600
        });
3601
        msg('work order confirmed; local-hosts.tsv written');
3602
        await refresh();
Bogdan Timofte authored 4 days ago
3603
      } catch (e) {
3604
        if (isAuthLost(e)) return;
3605
        msg(e.message);
3606
      }
Xdev Host Manager authored a week ago
3607
    }
3608

            
Xdev Host Manager authored a week ago
3609
    function renderHosts() {
3610
      const filter = $('filter').value.toLowerCase();
3611
      $('hosts').innerHTML = state.hosts
Bogdan Timofte authored 4 days ago
3612
        .slice()
3613
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
Xdev Host Manager authored a week ago
3614
        .filter(h => JSON.stringify(h).toLowerCase().includes(filter))
3615
        .map(h => {
3616
          const problems = state.problems.filter(p => p.host_id === h.id);
3617
          const cls = problems.length ? 'warn' : 'ok';
3618
          return `<tr data-id="${escapeHtml(h.id)}">
3619
            <td><button type="button" data-edit="${escapeHtml(h.id)}">${escapeHtml(h.id)}</button></td>
Bogdan Timofte authored 4 days ago
3620
            <td>${escapeHtml(h.ip || '')}</td>
Bogdan Timofte authored 4 days ago
3621
            <td>${renderNamePills(h)}</td>
Xdev Host Manager authored a week ago
3622
            <td>${(h.roles || []).map(n => `<span class="pill">${escapeHtml(n)}</span>`).join('')}</td>
3623
            <td><span class="pill ${cls}">${escapeHtml(h.monitoring || '')}</span></td>
3624
            <td>${escapeHtml(h.status || '')}</td>
3625
          </tr>`;
3626
        }).join('');
Bogdan Timofte authored 4 days ago
3627
      document.querySelectorAll('[data-edit]').forEach(button => button.addEventListener('click', () => {
3628
        editHost(button.dataset.edit).catch(e => {
3629
          if (!isAuthLost(e)) msg(e.message);
3630
        });
3631
      }));
Xdev Host Manager authored a week ago
3632
    }
3633

            
Bogdan Timofte authored 4 days ago
3634
    function renderNamePills(host) {
Bogdan Timofte authored 4 days ago
3635
      const canonical = host.fqdn ? `<span class="pill canonical">${escapeHtml(host.fqdn)}</span>` : '';
3636
      const aliases = (host.aliases || []).map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('');
3637
      const derivedAliases = (host.derived_aliases || []).map(name => `<span class="pill derived" title="derived alias">${escapeHtml(name)}</span>`).join('');
Bogdan Timofte authored 4 days ago
3638
      return canonical + aliases + derivedAliases;
Bogdan Timofte authored 4 days ago
3639
    }
3640

            
3641
    function vhostRows() {
3642
      return state.hosts.flatMap(host => (host.vhosts || []).map(vhost => ({
3643
        vhost,
3644
        host_id: host.id || '',
3645
        host_fqdn: host.fqdn || '',
3646
        ip: host.ip || '',
3647
        derived_aliases: shortAliasForFqdn(vhost) ? [shortAliasForFqdn(vhost)] : [],
3648
        monitoring: host.monitoring || '',
3649
        status: host.status || '',
3650
      })));
3651
    }
3652

            
3653
    function renderVhosts() {
3654
      const input = $('vhost-filter');
3655
      const filter = input ? input.value.toLowerCase() : '';
3656
      const rows = vhostRows()
3657
        .sort((a, b) => String(a.vhost || '').localeCompare(String(b.vhost || '')))
3658
        .filter(row => JSON.stringify(row).toLowerCase().includes(filter));
3659
      $('vhost-stats').innerHTML = [
3660
        ['shown', rows.length],
3661
        ['total', vhostRows().length],
3662
      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
3663
      $('vhosts').innerHTML = rows.length ? rows.map(row => `<tr>
3664
        <td><span class="pill vhost">${escapeHtml(row.vhost)}</span></td>
Bogdan Timofte authored 4 days ago
3665
        <td>
3666
          <div class="vhost-host">
3667
            <select class="vhost-host-select" data-vhost-select="${escapeHtml(row.vhost)}" data-current-host="${escapeHtml(row.host_fqdn)}">
3668
              ${renderVhostHostOptions(row.host_fqdn)}
3669
            </select>
3670
          </div>
3671
        </td>
Bogdan Timofte authored 4 days ago
3672
        <td>${escapeHtml(row.ip)}</td>
Bogdan Timofte authored 4 days ago
3673
        <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
3674
        <td><span class="pill">${escapeHtml(row.monitoring)}</span></td>
3675
        <td>${escapeHtml(row.status)}</td>
Bogdan Timofte authored 4 days ago
3676
        <td><button type="button" class="vhost-delete" data-vhost-delete="${escapeHtml(row.vhost)}">Delete</button></td>
3677
      </tr>`).join('') : '<tr><td colspan="7" class="muted">No vhosts.</td></tr>';
3678
      document.querySelectorAll('[data-vhost-select]').forEach(select => {
3679
        select.addEventListener('change', () => {
3680
          reassignVhostFromSelect(select).catch(e => {
Bogdan Timofte authored 4 days ago
3681
            if (!isAuthLost(e)) msg(e.message);
3682
            select.value = select.dataset.currentHost || '';
3683
          });
Bogdan Timofte authored 4 days ago
3684
        });
Bogdan Timofte authored 4 days ago
3685
      });
Bogdan Timofte authored 4 days ago
3686
      document.querySelectorAll('[data-vhost-delete]').forEach(button => {
3687
        button.addEventListener('click', () => {
3688
          deleteVhostInline(button.dataset.vhostDelete || '').catch(e => {
3689
            if (!isAuthLost(e)) msg(e.message);
3690
          });
3691
        });
3692
      });
3693
    }
3694

            
3695
    function renderVhostEditor() {
3696
      const select = $('vhost-new-host');
3697
      const current = select.value || '';
3698
      select.innerHTML = renderVhostHostOptions(current);
Bogdan Timofte authored 4 days ago
3699
    }
3700

            
3701
    function renderVhostHostOptions(selectedHostFqdn) {
3702
      return state.hosts
3703
        .slice()
3704
        .filter(host => (host.status || '') !== 'retired')
3705
        .sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')))
3706
        .map(host => {
3707
          const fqdn = host.fqdn || '';
3708
          const selected = fqdn === selectedHostFqdn ? ' selected' : '';
Bogdan Timofte authored 4 days ago
3709
          return `<option value="${escapeHtml(fqdn)}"${selected}>${escapeHtml(fqdn)}</option>`;
Bogdan Timofte authored 4 days ago
3710
        }).join('');
Bogdan Timofte authored 4 days ago
3711
    }
3712

            
3713
    function shortAliasForFqdn(name) {
3714
      const suffix = '.madagascar.xdev.ro';
3715
      name = String(name || '').toLowerCase();
3716
      return name.endsWith(suffix) ? name.slice(0, -suffix.length) : '';
Bogdan Timofte authored 4 days ago
3717
    }
3718

            
Bogdan Timofte authored 4 days ago
3719
    async function reassignVhostFromSelect(select) {
Bogdan Timofte authored 4 days ago
3720
      const vhost = select.dataset.vhostSelect || '';
3721
      const fromHost = select.dataset.currentHost || '';
3722
      const toHost = select.value || '';
3723
      if (!vhost || !toHost || toHost === fromHost) return;
3724
      select.disabled = true;
3725
      try {
3726
        await api('/api/vhosts/reassign', {
3727
          method: 'POST',
3728
          headers: { 'Content-Type': 'application/json' },
3729
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: toHost }),
3730
        });
3731
        msg(`vhost ${vhost} moved`);
3732
        await refresh();
3733
      } finally {
3734
        select.disabled = false;
3735
      }
3736
    }
3737

            
Bogdan Timofte authored 4 days ago
3738
    async function addVhostInline() {
3739
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare.')) return;
3740
      const nameInput = $('vhost-new-name');
3741
      const hostSelect = $('vhost-new-host');
3742
      const vhost = (nameInput.value || '').trim().toLowerCase();
3743
      const hostFqdn = hostSelect.value || '';
3744
      if (!vhost || !hostFqdn) return;
3745
      $('vhost-add').disabled = true;
3746
      nameInput.disabled = true;
3747
      hostSelect.disabled = true;
3748
      try {
3749
        await api('/api/vhosts/upsert', {
3750
          method: 'POST',
3751
          headers: { 'Content-Type': 'application/json' },
3752
          body: JSON.stringify({ vhost_fqdn: vhost, host_fqdn: hostFqdn }),
3753
        });
3754
        nameInput.value = '';
3755
        msg(`vhost ${vhost} saved`);
3756
        await refresh();
3757
      } finally {
3758
        $('vhost-add').disabled = false;
3759
        nameInput.disabled = false;
3760
        hostSelect.disabled = false;
3761
      }
3762
    }
3763

            
3764
    async function deleteVhostInline(vhost) {
3765
      if (!await ensureAuthenticated('Autentifica-te inainte de stergere.')) return;
3766
      if (!vhost || !confirm(`Delete ${vhost}?`)) return;
3767
      await api('/api/vhosts/delete', {
3768
        method: 'POST',
3769
        headers: { 'Content-Type': 'application/json' },
3770
        body: JSON.stringify({ vhost_fqdn: vhost, confirm: vhost }),
3771
      });
3772
      msg(`vhost ${vhost} deleted`);
3773
      await refresh();
3774
    }
3775

            
Bogdan Timofte authored 4 days ago
3776
    async function editHost(id) {
3777
      if (!await ensureAuthenticated('Autentifica-te inainte de editare.')) return;
Xdev Host Manager authored a week ago
3778
      const host = state.hosts.find(h => h.id === id);
3779
      if (!host) return;
3780
      const form = $('host-form');
Bogdan Timofte authored 5 days ago
3781
      clearHostFormMessage();
Bogdan Timofte authored 4 days ago
3782
      for (const key of ['id', 'fqdn', 'status', 'ip', 'monitoring', 'notes']) hostField(key).value = host[key] || '';
3783
      hostField('aliases').value = (host.aliases || []).join('\n');
Bogdan Timofte authored 5 days ago
3784
      hostField('roles').value = (host.roles || []).join(' ');
3785
      hostField('sources').value = (host.sources || []).join(' ');
Bogdan Timofte authored 5 days ago
3786
      openHostModal('Edit host');
3787
    }
3788

            
Bogdan Timofte authored 4 days ago
3789
    async function newHost() {
3790
      if (!await ensureAuthenticated('Autentifica-te inainte de adaugarea unui host.')) return;
Bogdan Timofte authored 5 days ago
3791
      const form = $('host-form');
3792
      form.reset();
Bogdan Timofte authored 5 days ago
3793
      clearHostFormMessage();
3794
      hostField('status').value = 'active';
3795
      hostField('monitoring').value = 'pending';
Bogdan Timofte authored 5 days ago
3796
      openHostModal('New host');
3797
    }
3798

            
3799
    function openHostModal(title) {
3800
      $('host-modal-title').textContent = title || 'Edit host';
3801
      $('host-modal').hidden = false;
3802
      document.body.style.overflow = 'hidden';
Bogdan Timofte authored 5 days ago
3803
      hostFormSnapshot = hostFormState();
3804
      hostField('id').focus();
3805
    }
3806

            
3807
    function requestCloseHostModal() {
3808
      if ($('save-host').disabled) return;
3809
      if (hostFormDirty() && !confirm('Discard unsaved host changes?')) return;
3810
      closeHostModal();
Bogdan Timofte authored 5 days ago
3811
    }
3812

            
3813
    function closeHostModal() {
3814
      $('host-modal').hidden = true;
3815
      document.body.style.overflow = '';
Bogdan Timofte authored 5 days ago
3816
      setHostFormBusy(false);
3817
      clearHostFormMessage();
3818
      hostFormSnapshot = '';
3819
    }
3820

            
3821
    function hostField(name) {
3822
      return $('host-form').elements.namedItem(name);
3823
    }
3824

            
3825
    function hostFormState() {
3826
      return JSON.stringify(formObject($('host-form')));
3827
    }
3828

            
3829
    function hostFormDirty() {
3830
      return !$('host-modal').hidden && hostFormSnapshot && hostFormState() !== hostFormSnapshot;
3831
    }
3832

            
3833
    function setHostFormBusy(busy) {
3834
      $('save-host').disabled = busy;
3835
      $('delete-host').disabled = busy;
3836
      $('close-host-modal').disabled = busy;
3837
    }
3838

            
3839
    function setHostFormMessage(text, isError = false) {
3840
      const message = $('host-form-message');
3841
      message.textContent = text || '';
3842
      message.classList.toggle('error', !!isError);
3843
    }
3844

            
3845
    function clearHostFormMessage() {
3846
      setHostFormMessage('');
Xdev Host Manager authored a week ago
3847
    }
3848

            
3849
    function formObject(form) {
3850
      return Object.fromEntries(new FormData(form).entries());
3851
    }
3852

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

            
Bogdan Timofte authored 6 days ago
3858
    const ACCOUNT_STORAGE_KEY = 'mla_last_account';
3859

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

            
3865
    if (loginAccount) {
3866
      const rememberedAccount = localStorage.getItem(ACCOUNT_STORAGE_KEY);
3867
      if (rememberedAccount && !loginAccount.value) loginAccount.value = rememberedAccount;
3868
      loginAccount.addEventListener('input', () => {
3869
        const value = (loginAccount.value || '').trim();
3870
        if (value) localStorage.setItem(ACCOUNT_STORAGE_KEY, value);
3871
      });
3872
    }
3873

            
Xdev Host Manager authored a week ago
3874
    function setOtpDigit(idx, value) {
3875
      const digit = (value || '').replace(/\D/g, '').slice(0, 1);
Bogdan Timofte authored 4 days ago
3876
      otpDigits[idx].value = digit;
Xdev Host Manager authored a week ago
3877
      otpDigits[idx].classList.toggle('filled', !!digit);
3878
    }
3879

            
Bogdan Timofte authored 4 days ago
3880
    // Move focus to the next empty box: forward from idx, then wrapping to the
3881
    // start. This lets out-of-order entry continue (e.g. after the last box,
3882
    // jump back to the first still-empty box). Stays put when all boxes are full.
3883
    function advanceFocus(idx) {
3884
      for (let i = idx + 1; i < otpDigits.length; i++) {
3885
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3886
      }
3887
      for (let i = 0; i <= idx; i++) {
3888
        if (!otpDigits[i].value) { otpDigits[i].focus(); return; }
3889
      }
3890
    }
3891

            
Bogdan Timofte authored 4 days ago
3892
    // Spread multiple digits across boxes starting at startIdx. Used for paste
3893
    // and for Safari OTP autofill, which drops the whole code into the first box.
Xdev Host Manager authored a week ago
3894
    function fillOtp(text, startIdx = 0) {
Bogdan Timofte authored 4 days ago
3895
      const digits = (text || '').replace(/\D/g, '').split('');
3896
      if (!digits.length) return;
3897
      let last = startIdx;
3898
      for (let i = 0; i < digits.length && startIdx + i < otpDigits.length; i++) {
3899
        last = startIdx + i;
3900
        setOtpDigit(last, digits[i]);
Xdev Host Manager authored a week ago
3901
      }
Bogdan Timofte authored 4 days ago
3902
      syncOtpFields();
Bogdan Timofte authored 4 days ago
3903
      advanceFocus(last);
Xdev Host Manager authored a week ago
3904
      maybeSubmitOtp();
3905
    }
3906

            
Bogdan Timofte authored 4 days ago
3907
    function getOtp() { return otpDigits.map(i => i.value).join(''); }
3908
    function syncOtpFields() { if (otpHidden) otpHidden.value = getOtp(); }
3909
    function otpReady() { return otpDigits.every(i => /^\d$/.test(i.value)); }
3910
    function maybeSubmitOtp() {
3911
      if (otpReady()) setTimeout(() => $('login-form').requestSubmit(), 300);
Bogdan Timofte authored 6 days ago
3912
    }
3913
    function clearOtp() {
Bogdan Timofte authored 4 days ago
3914
      otpDigits.forEach(i => { i.value = ''; i.classList.remove('filled'); });
Bogdan Timofte authored 6 days ago
3915
      if (otpHidden) otpHidden.value = '';
Bogdan Timofte authored 4 days ago
3916
      // Same conditional focus as on load: don't steal focus to the OTP boxes for
3917
      // an unknown operator, so Safari's autofill anchor on the username stays.
3918
      if (loginAccount && !loginAccount.value) loginAccount.focus();
3919
      else otpDigits[0].focus();
Bogdan Timofte authored 6 days ago
3920
    }
3921

            
Bogdan Timofte authored 4 days ago
3922
    otpDigits.forEach((input, idx) => {
3923
      input.addEventListener('input', () => {
Bogdan Timofte authored 4 days ago
3924
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3925
        // A single box may receive several digits at once (autofill / typing fast).
3926
        if (input.value.replace(/\D/g, '').length > 1) {
3927
          fillOtp(input.value, idx);
3928
          return;
3929
        }
3930
        setOtpDigit(idx, input.value);
Bogdan Timofte authored 4 days ago
3931
        syncOtpFields();
Bogdan Timofte authored 4 days ago
3932
        if (input.value) advanceFocus(idx);
Bogdan Timofte authored 4 days ago
3933
        maybeSubmitOtp();
3934
      });
Bogdan Timofte authored 4 days ago
3935

            
3936
      input.addEventListener('paste', (e) => {
3937
        e.preventDefault();
Bogdan Timofte authored 4 days ago
3938
        $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3939
        const text = (e.clipboardData || window.clipboardData).getData('text');
3940
        fillOtp(text, idx);
Bogdan Timofte authored 4 days ago
3941
      });
Bogdan Timofte authored 4 days ago
3942

            
3943
      input.addEventListener('keydown', (e) => {
3944
        if (e.key === 'Backspace') {
3945
          e.preventDefault();
Bogdan Timofte authored 4 days ago
3946
          $('login-error').textContent = '';
Bogdan Timofte authored 4 days ago
3947
          if (input.value) { setOtpDigit(idx, ''); }
3948
          else if (idx > 0) { setOtpDigit(idx - 1, ''); otpDigits[idx - 1].focus(); }
3949
          syncOtpFields();
3950
        } else if (e.key === 'ArrowLeft' && idx > 0) {
3951
          e.preventDefault();
3952
          otpDigits[idx - 1].focus();
3953
        } else if (e.key === 'ArrowRight' && idx < otpDigits.length - 1) {
3954
          e.preventDefault();
3955
          otpDigits[idx + 1].focus();
3956
        }
3957
      });
3958
    });
3959

            
Bogdan Timofte authored 4 days ago
3960
    // Focus the first OTP box only for a returning operator (username known).
3961
    // For an unknown operator, leave focus on the username field so Safari can
3962
    // present its OTP autofill anchored there without being dismissed by a focus
3963
    // change (pbx-admin pattern).
3964
    if (loginAccount && loginAccount.value) otpDigits[0].focus();
3965
    else if (loginAccount) loginAccount.focus();
3966
    else otpDigits[0].focus();
Xdev Host Manager authored a week ago
3967

            
Bogdan Timofte authored 5 days ago
3968
    document.querySelectorAll('[data-page-link]').forEach(link => {
Bogdan Timofte authored 4 days ago
3969
      link.addEventListener('click', async (event) => {
Bogdan Timofte authored 5 days ago
3970
        event.preventDefault();
Bogdan Timofte authored 4 days ago
3971
        if (!await ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')) return;
Bogdan Timofte authored 5 days ago
3972
        showPage(link.dataset.pageLink, true);
3973
      });
3974
    });
3975

            
Bogdan Timofte authored 4 days ago
3976
    window.addEventListener('popstate', () => {
3977
      ensureAuthenticated('Autentifica-te pentru a schimba sectiunea.')
3978
        .then(authenticated => { if (authenticated) showPage(currentPage()); })
3979
        .catch(e => { if (!isAuthLost(e)) msg(e.message); });
3980
    });
Bogdan Timofte authored 5 days ago
3981

            
Bogdan Timofte authored 4 days ago
3982
    async function copyText(text) {
3983
      if (navigator.clipboard && window.isSecureContext) {
3984
        await navigator.clipboard.writeText(text);
3985
        return;
3986
      }
3987
      const input = document.createElement('textarea');
3988
      input.value = text;
3989
      input.setAttribute('readonly', '');
3990
      input.style.position = 'fixed';
3991
      input.style.left = '-10000px';
3992
      document.body.appendChild(input);
3993
      input.select();
3994
      document.execCommand('copy');
3995
      document.body.removeChild(input);
3996
    }
3997

            
3998
    $('copy-build').addEventListener('click', async () => {
3999
      try {
4000
        await copyText($('copy-build').dataset.buildDetails || '');
4001
        if (state.authenticated) msg('build details copied');
4002
      } catch (e) {
4003
        if (state.authenticated) msg('copy failed');
4004
      }
4005
    });
4006

            
Xdev Host Manager authored a week ago
4007
    $('login-form').addEventListener('submit', async (event) => {
4008
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4009
      if (!otpReady() || $('login-form').classList.contains('busy')) return;
Xdev Host Manager authored a week ago
4010
      $('login-form').classList.add('busy');
Xdev Host Manager authored a week ago
4011
      $('login-error').textContent = '';
Xdev Host Manager authored a week ago
4012
      try {
Xdev Host Manager authored a week ago
4013
        await api('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ otp: getOtp() }) });
Xdev Host Manager authored a week ago
4014
        await refresh();
Xdev Host Manager authored a week ago
4015
      } catch (e) {
4016
        showLogin(e.message === 'invalid_otp' ? 'Cod incorect.' : e.message);
4017
      } finally {
Xdev Host Manager authored a week ago
4018
        $('login-form').classList.remove('busy');
Xdev Host Manager authored a week ago
4019
      }
Xdev Host Manager authored a week ago
4020
    });
4021

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

            
Bogdan Timofte authored 4 days ago
4027
    $('refresh').addEventListener('click', () => refresh().catch(e => {
4028
      if (!isAuthLost(e)) msg(e.message);
4029
    }));
Xdev Host Manager authored a week ago
4030
    $('filter').addEventListener('input', renderHosts);
Bogdan Timofte authored 4 days ago
4031
    $('vhost-filter').addEventListener('input', renderVhosts);
Bogdan Timofte authored 4 days ago
4032
    $('vhost-add').addEventListener('click', () => {
4033
      addVhostInline().catch(e => {
4034
        if (!isAuthLost(e)) msg(e.message);
4035
      });
4036
    });
4037
    $('vhost-new-name').addEventListener('keydown', (event) => {
4038
      if (event.key !== 'Enter') return;
4039
      event.preventDefault();
4040
      addVhostInline().catch(e => {
4041
        if (!isAuthLost(e)) msg(e.message);
4042
      });
4043
    });
Bogdan Timofte authored 4 days ago
4044
    $('new-host').addEventListener('click', () => {
4045
      newHost().catch(e => {
4046
        if (!isAuthLost(e)) msg(e.message);
4047
      });
4048
    });
Bogdan Timofte authored 4 days ago
4049
    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => {
4050
      if (!isAuthLost(e)) msg(e.message);
4051
    }));
Bogdan Timofte authored 5 days ago
4052
    $('close-host-modal').addEventListener('click', requestCloseHostModal);
Bogdan Timofte authored 5 days ago
4053
    $('host-modal').addEventListener('click', (event) => {
4054
      if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();
4055
    });
Bogdan Timofte authored 5 days ago
4056
    window.addEventListener('keydown', (event) => {
Bogdan Timofte authored 5 days ago
4057
      if (event.key === 'Escape' && !$('host-modal').hidden) requestCloseHostModal();
Bogdan Timofte authored 5 days ago
4058
    });
Xdev Host Manager authored a week ago
4059

            
Xdev Host Manager authored a week ago
4060
    $('host-form').addEventListener('submit', async (event) => {
4061
      event.preventDefault();
Bogdan Timofte authored 4 days ago
4062
      if (!await ensureAuthenticated('Autentifica-te inainte de salvare. Modificarile raman in formular.')) return;
Bogdan Timofte authored 5 days ago
4063
      setHostFormBusy(true);
4064
      setHostFormMessage('Saving...');
Xdev Host Manager authored a week ago
4065
      try {
4066
        await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
Bogdan Timofte authored 5 days ago
4067
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
4068
        closeHostModal();
Xdev Host Manager authored a week ago
4069
        msg('host saved');
4070
        await refresh();
Bogdan Timofte authored 5 days ago
4071
      } catch (e) {
Bogdan Timofte authored 4 days ago
4072
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4073
        setHostFormMessage(e.message, true);
4074
        msg(e.message);
4075
      } finally {
4076
        setHostFormBusy(false);
4077
      }
4078
    });
4079

            
4080
    $('host-form').addEventListener('invalid', (event) => {
4081
      setHostFormMessage('Complete the required host fields before saving.', true);
4082
    }, true);
4083

            
4084
    $('host-form').addEventListener('input', () => {
4085
      if ($('host-form-message').classList.contains('error')) clearHostFormMessage();
Xdev Host Manager authored a week ago
4086
    });
4087

            
4088
    $('delete-host').addEventListener('click', async () => {
Bogdan Timofte authored 5 days ago
4089
      const id = hostField('id').value;
Xdev Host Manager authored a week ago
4090
      if (!id || !confirm(`Delete ${id}?`)) return;
Bogdan Timofte authored 5 days ago
4091
      setHostFormBusy(true);
4092
      setHostFormMessage('Deleting...');
Xdev Host Manager authored a week ago
4093
      try {
4094
        await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
4095
        $('host-form').reset();
Bogdan Timofte authored 5 days ago
4096
        hostFormSnapshot = hostFormState();
Bogdan Timofte authored 5 days ago
4097
        closeHostModal();
Xdev Host Manager authored a week ago
4098
        msg('host deleted');
4099
        await refresh();
Bogdan Timofte authored 5 days ago
4100
      } catch (e) {
Bogdan Timofte authored 4 days ago
4101
        if (isAuthLost(e)) return;
Bogdan Timofte authored 5 days ago
4102
        setHostFormMessage(e.message, true);
4103
        msg(e.message);
4104
      } finally {
4105
        setHostFormBusy(false);
4106
      }
Xdev Host Manager authored a week ago
4107
    });
4108

            
4109
    $('write-tsv').addEventListener('click', async () => {
Bogdan Timofte authored 4 days ago
4110
      if (!confirm('Write config/local-hosts.tsv from the runtime registry?')) return;
Xdev Host Manager authored a week ago
4111
      try {
4112
        await api('/api/render/local-hosts-tsv', { method: 'POST' });
4113
        msg('local-hosts.tsv written');
Bogdan Timofte authored 4 days ago
4114
      } catch (e) {
4115
        if (!isAuthLost(e)) msg(e.message);
4116
      }
Xdev Host Manager authored a week ago
4117
    });
4118

            
Bogdan Timofte authored 4 days ago
4119
    refresh().catch(e => {
4120
      if (!isAuthLost(e)) showLogin(e.message);
4121
    });
Xdev Host Manager authored a week ago
4122
  </script>
4123
</body>
4124
</html>
4125
HTML
Bogdan Timofte authored 6 days ago
4126
    $html =~ s/__HOST_MANAGER_BUILD_TITLE__/$build_title/g;
4127
    $html =~ s/__HOST_MANAGER_BUILD__/$build/g;
Bogdan Timofte authored 4 days ago
4128
    $html =~ s/__HOST_MANAGER_BUILD_DETAILS__/$build_details/g;
Bogdan Timofte authored 6 days ago
4129
    return $html;
Xdev Host Manager authored a week ago
4130
}