@@ -97,6 +97,26 @@ cert_field() {
|
||
| 97 | 97 |
esac |
| 98 | 98 |
} |
| 99 | 99 |
|
| 100 |
+cert_dns_names_json() {
|
|
| 101 |
+ local cert="$1" |
|
| 102 |
+ local names name first=1 |
|
| 103 |
+ names="$("$OPENSSL" x509 -in "$cert" -noout -ext subjectAltName 2>/dev/null \
|
|
| 104 |
+ | sed -n '/DNS:/,$p' \ |
|
| 105 |
+ | tr ',' '\n' \ |
|
| 106 |
+ | sed -n 's/^[[:space:]]*DNS://p')" |
|
| 107 |
+ |
|
| 108 |
+ printf '[' |
|
| 109 |
+ while IFS= read -r name; do |
|
| 110 |
+ [[ -n "$name" ]] || continue |
|
| 111 |
+ if [[ "$first" -eq 0 ]]; then |
|
| 112 |
+ printf ',' |
|
| 113 |
+ fi |
|
| 114 |
+ first=0 |
|
| 115 |
+ json_escape "$name" |
|
| 116 |
+ done <<< "$names" |
|
| 117 |
+ printf ']' |
|
| 118 |
+} |
|
| 119 |
+ |
|
| 100 | 120 |
status_json() {
|
| 101 | 121 |
need_openssl |
| 102 | 122 |
if [[ ! -f "$ca_cert" ]]; then |
@@ -137,6 +157,7 @@ list_json() {
|
||
| 137 | 157 |
first=0 |
| 138 | 158 |
printf '{"name":'; json_escape "$name"
|
| 139 | 159 |
printf ',"subject":'; json_escape "$(cert_field "$cert" subject)" |
| 160 |
+ printf ',"dns_names":'; cert_dns_names_json "$cert" |
|
| 140 | 161 |
printf ',"issuer":'; json_escape "$(cert_field "$cert" issuer)" |
| 141 | 162 |
printf ',"serial":'; json_escape "$(cert_field "$cert" serial)" |
| 142 | 163 |
printf ',"not_before":'; json_escape "$(cert_field "$cert" not_before)" |
@@ -110,7 +110,7 @@ sub handle_client {
|
||
| 110 | 110 |
my ($path, $query) = split /\?/, $target, 2; |
| 111 | 111 |
my %query = parse_params($query || ''); |
| 112 | 112 |
|
| 113 |
- if ($method eq 'GET' && $path eq '/') {
|
|
| 113 |
+ if ($method eq 'GET' && app_page_path($path)) {
|
|
| 114 | 114 |
return send_html($client, 200, app_html()); |
| 115 | 115 |
} |
| 116 | 116 |
if ($method eq 'GET' && $path eq '/healthz') {
|
@@ -163,6 +163,10 @@ sub handle_client {
|
||
| 163 | 163 |
if ($method eq 'GET' && $path eq '/download/ca.crt') {
|
| 164 | 164 |
return send_file($client, ca_cert_path(), 'application/x-pem-file; charset=utf-8', 'xdev-madagascar-host-ca.crt'); |
| 165 | 165 |
} |
| 166 |
+ if ($method eq 'GET' && $path =~ m{\A/download/ca/cert/([A-Za-z0-9_.-]+)\.crt\z}) {
|
|
| 167 |
+ my $name = $1; |
|
| 168 |
+ return send_file($client, ca_issued_cert_path($name), 'application/x-pem-file; charset=utf-8', "$name.crt"); |
|
| 169 |
+ } |
|
| 166 | 170 |
|
| 167 | 171 |
if ($method eq 'POST' && $path =~ m{^/api/}) {
|
| 168 | 172 |
if ($path eq '/api/hosts/upsert') {
|
@@ -193,6 +197,11 @@ sub handle_client {
|
||
| 193 | 197 |
return send_json($client, 404, { error => 'not_found' });
|
| 194 | 198 |
} |
| 195 | 199 |
|
| 200 |
+sub app_page_path {
|
|
| 201 |
+ my ($path) = @_; |
|
| 202 |
+ return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca)\z};
|
|
| 203 |
+} |
|
| 204 |
+ |
|
| 196 | 205 |
sub load_registry {
|
| 197 | 206 |
return parse_hosts_yaml(read_file($opt{data}));
|
| 198 | 207 |
} |
@@ -549,6 +558,12 @@ sub ca_cert_path {
|
||
| 549 | 558 |
return ca_dir() . "/certs/ca.cert.pem"; |
| 550 | 559 |
} |
| 551 | 560 |
|
| 561 |
+sub ca_issued_cert_path {
|
|
| 562 |
+ my ($name) = @_; |
|
| 563 |
+ die "unsafe certificate name\n" unless $name =~ /\A[A-Za-z0-9_.-]+\z/; |
|
| 564 |
+ return ca_dir() . "/issued/$name.cert.pem"; |
|
| 565 |
+} |
|
| 566 |
+ |
|
| 552 | 567 |
sub ca_manager_json {
|
| 553 | 568 |
my ($command) = @_; |
| 554 | 569 |
my $script = ca_script_path(); |
@@ -1326,10 +1341,17 @@ sub app_html {
|
||
| 1326 | 1341 |
|
| 1327 | 1342 |
/* ── App shell (hidden until authenticated) ── */ |
| 1328 | 1343 |
#app { display: none; }
|
| 1329 |
- header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: var(--panel); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 2; }
|
|
| 1344 |
+ 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; }
|
|
| 1330 | 1345 |
h1 { margin: 0; font-size: 17px; font-weight: 700; }
|
| 1331 |
- .header-right { display: flex; align-items: center; gap: 10px; }
|
|
| 1346 |
+ nav { display: flex; align-items: center; gap: 4px; min-width: 0; overflow-x: auto; }
|
|
| 1347 |
+ nav a { color: var(--muted); text-decoration: none; padding: 7px 10px; border-radius: 6px; white-space: nowrap; font-weight: 650; }
|
|
| 1348 |
+ nav a:hover { color: var(--ink); background: var(--soft); }
|
|
| 1349 |
+ nav a.active { color: var(--accent); background: #e8f0fe; }
|
|
| 1350 |
+ .header-right { display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
|
|
| 1351 |
+ #message { max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
| 1332 | 1352 |
main { padding: 16px; display: grid; gap: 16px; max-width: 1280px; margin: 0 auto; }
|
| 1353 |
+ .page { display: grid; gap: 16px; }
|
|
| 1354 |
+ .page[hidden] { display: none; }
|
|
| 1333 | 1355 |
.toolbar, .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
|
| 1334 | 1356 |
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px; }
|
| 1335 | 1357 |
.panel { overflow: hidden; }
|
@@ -1357,6 +1379,10 @@ sub app_html {
|
||
| 1357 | 1379 |
.span2 { grid-column: 1 / -1; }
|
| 1358 | 1380 |
label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
| 1359 | 1381 |
.muted { color: var(--muted); }
|
| 1382 |
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; }
|
|
| 1383 |
+ .ca-detail { display: grid; gap: 6px; min-width: 0; }
|
|
| 1384 |
+ .ca-fingerprint { overflow-wrap: anywhere; }
|
|
| 1385 |
+ .ca-empty { padding: 12px 14px; }
|
|
| 1360 | 1386 |
.build-badge {
|
| 1361 | 1387 |
position: fixed; |
| 1362 | 1388 |
right: 10px; |
@@ -1387,7 +1413,53 @@ sub app_html {
|
||
| 1387 | 1413 |
.work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
|
| 1388 | 1414 |
.work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
|
| 1389 | 1415 |
.work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
|
| 1416 |
+ .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
|
|
| 1417 |
+ .host-tools input { max-width: 240px; }
|
|
| 1418 |
+ .modal-backdrop {
|
|
| 1419 |
+ position: fixed; |
|
| 1420 |
+ inset: 0; |
|
| 1421 |
+ z-index: 10; |
|
| 1422 |
+ display: grid; |
|
| 1423 |
+ align-items: start; |
|
| 1424 |
+ justify-items: center; |
|
| 1425 |
+ padding: 72px 16px 24px; |
|
| 1426 |
+ background: rgba(21,32,51,.48); |
|
| 1427 |
+ overflow: auto; |
|
| 1428 |
+ } |
|
| 1429 |
+ .modal-backdrop[hidden] { display: none; }
|
|
| 1430 |
+ .modal {
|
|
| 1431 |
+ width: min(840px, 100%); |
|
| 1432 |
+ max-height: calc(100dvh - 96px); |
|
| 1433 |
+ overflow: auto; |
|
| 1434 |
+ background: var(--panel); |
|
| 1435 |
+ border: 1px solid var(--line); |
|
| 1436 |
+ border-radius: 8px; |
|
| 1437 |
+ box-shadow: 0 20px 60px rgba(21,32,51,.26); |
|
| 1438 |
+ } |
|
| 1439 |
+ .modal-head {
|
|
| 1440 |
+ position: sticky; |
|
| 1441 |
+ top: 0; |
|
| 1442 |
+ z-index: 1; |
|
| 1443 |
+ display: flex; |
|
| 1444 |
+ align-items: center; |
|
| 1445 |
+ justify-content: space-between; |
|
| 1446 |
+ gap: 12px; |
|
| 1447 |
+ padding: 12px 14px; |
|
| 1448 |
+ border-bottom: 1px solid var(--line); |
|
| 1449 |
+ background: #fafbfc; |
|
| 1450 |
+ } |
|
| 1451 |
+ .modal-head h2 { margin: 0; font-size: 14px; }
|
|
| 1452 |
+ .modal-close { min-width: 34px; justify-content: center; padding: 7px; }
|
|
| 1453 |
+ .form-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
| 1390 | 1454 |
@media (max-width: 760px) {
|
| 1455 |
+ header { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
|
|
| 1456 |
+ .header-right { justify-content: flex-start; flex-wrap: wrap; }
|
|
| 1457 |
+ #message { max-width: 100%; }
|
|
| 1458 |
+ .panel-head { align-items: stretch; flex-direction: column; }
|
|
| 1459 |
+ .host-tools { justify-content: flex-start; flex-wrap: wrap; }
|
|
| 1460 |
+ .host-tools input { max-width: none; }
|
|
| 1461 |
+ .modal-backdrop { padding-top: 16px; }
|
|
| 1462 |
+ .modal { max-height: calc(100dvh - 32px); }
|
|
| 1391 | 1463 |
.grid { grid-template-columns: 1fr; }
|
| 1392 | 1464 |
table { min-width: 760px; }
|
| 1393 | 1465 |
.table-wrap { overflow-x: auto; }
|
@@ -1436,71 +1508,115 @@ sub app_html {
|
||
| 1436 | 1508 |
<div id="app"> |
| 1437 | 1509 |
<header> |
| 1438 | 1510 |
<h1>Madagascar Local Authority</h1> |
| 1511 |
+ <nav aria-label="Sections"> |
|
| 1512 |
+ <a href="/overview" data-page-link="overview">Overview</a> |
|
| 1513 |
+ <a href="/hosts" data-page-link="hosts">Hosts</a> |
|
| 1514 |
+ <a href="/dns" data-page-link="dns">DNS</a> |
|
| 1515 |
+ <a href="/work-orders" data-page-link="work-orders">Work Orders</a> |
|
| 1516 |
+ <a href="/ca" data-page-link="ca">Local CA</a> |
|
| 1517 |
+ </nav> |
|
| 1439 | 1518 |
<div class="header-right"> |
| 1440 | 1519 |
<span class="muted" id="app-updated"></span> |
| 1520 |
+ <span id="message" class="muted"></span> |
|
| 1521 |
+ <button id="refresh">Refresh</button> |
|
| 1441 | 1522 |
<button type="button" id="logout">Logout</button> |
| 1442 | 1523 |
</div> |
| 1443 | 1524 |
</header> |
| 1444 | 1525 |
<main> |
| 1445 |
- <section class="toolbar"> |
|
| 1446 |
- <button id="refresh">Refresh</button> |
|
| 1447 |
- <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a> |
|
| 1448 |
- <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a> |
|
| 1449 |
- <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a> |
|
| 1450 |
- <button id="write-tsv">Write local-hosts.tsv</button> |
|
| 1451 |
- <span id="message" class="muted"></span> |
|
| 1526 |
+ <section class="page" id="page-overview" data-page="overview"> |
|
| 1527 |
+ <section class="panel"> |
|
| 1528 |
+ <div class="panel-head"> |
|
| 1529 |
+ <h2>Overview</h2> |
|
| 1530 |
+ <div class="stats" id="stats"></div> |
|
| 1531 |
+ </div> |
|
| 1532 |
+ <div class="problems" id="problems"></div> |
|
| 1533 |
+ </section> |
|
| 1452 | 1534 |
</section> |
| 1453 | 1535 |
|
| 1454 |
- <section class="panel"> |
|
| 1455 |
- <div class="panel-head"> |
|
| 1456 |
- <h2>Overview</h2> |
|
| 1457 |
- <div class="stats" id="stats"></div> |
|
| 1458 |
- </div> |
|
| 1459 |
- <div class="problems" id="problems"></div> |
|
| 1536 |
+ <section class="page" id="page-hosts" data-page="hosts" hidden> |
|
| 1537 |
+ <section class="panel"> |
|
| 1538 |
+ <div class="panel-head"> |
|
| 1539 |
+ <h2>Hosts</h2> |
|
| 1540 |
+ <div class="host-tools"> |
|
| 1541 |
+ <input id="filter" placeholder="filter"> |
|
| 1542 |
+ <button type="button" id="new-host">New host</button> |
|
| 1543 |
+ </div> |
|
| 1544 |
+ </div> |
|
| 1545 |
+ <div class="table-wrap"> |
|
| 1546 |
+ <table> |
|
| 1547 |
+ <thead> |
|
| 1548 |
+ <tr> |
|
| 1549 |
+ <th style="width: 120px">ID</th> |
|
| 1550 |
+ <th style="width: 130px">hosts_ip</th> |
|
| 1551 |
+ <th style="width: 130px">dns_ip</th> |
|
| 1552 |
+ <th>Names</th> |
|
| 1553 |
+ <th style="width: 150px">Roles</th> |
|
| 1554 |
+ <th style="width: 110px">Monitoring</th> |
|
| 1555 |
+ <th style="width: 90px">Status</th> |
|
| 1556 |
+ </tr> |
|
| 1557 |
+ </thead> |
|
| 1558 |
+ <tbody id="hosts"></tbody> |
|
| 1559 |
+ </table> |
|
| 1560 |
+ </div> |
|
| 1561 |
+ </section> |
|
| 1460 | 1562 |
</section> |
| 1461 | 1563 |
|
| 1462 |
- <section class="panel"> |
|
| 1463 |
- <div class="panel-head"> |
|
| 1464 |
- <h2>Local Certificate Authority</h2> |
|
| 1465 |
- <a class="linkbtn" href="/download/ca.crt">ca.crt</a> |
|
| 1466 |
- </div> |
|
| 1467 |
- <div class="problems" id="ca-status"></div> |
|
| 1564 |
+ <section class="page" id="page-dns" data-page="dns" hidden> |
|
| 1565 |
+ <section class="toolbar"> |
|
| 1566 |
+ <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a> |
|
| 1567 |
+ <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a> |
|
| 1568 |
+ <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a> |
|
| 1569 |
+ <button id="write-tsv">Write local-hosts.tsv</button> |
|
| 1570 |
+ </section> |
|
| 1468 | 1571 |
</section> |
| 1469 | 1572 |
|
| 1470 |
- <section class="panel"> |
|
| 1471 |
- <div class="panel-head"> |
|
| 1472 |
- <h2>Work Orders</h2> |
|
| 1473 |
- <div class="stats" id="wo-stats"></div> |
|
| 1474 |
- </div> |
|
| 1475 |
- <div class="problems" id="work-orders"></div> |
|
| 1573 |
+ <section class="page" id="page-work-orders" data-page="work-orders" hidden> |
|
| 1574 |
+ <section class="panel"> |
|
| 1575 |
+ <div class="panel-head"> |
|
| 1576 |
+ <h2>Work Orders</h2> |
|
| 1577 |
+ <div class="stats" id="wo-stats"></div> |
|
| 1578 |
+ </div> |
|
| 1579 |
+ <div class="problems" id="work-orders"></div> |
|
| 1580 |
+ </section> |
|
| 1476 | 1581 |
</section> |
| 1477 | 1582 |
|
| 1478 |
- <section class="panel"> |
|
| 1479 |
- <div class="panel-head"> |
|
| 1480 |
- <h2>Hosts</h2> |
|
| 1481 |
- <input id="filter" placeholder="filter" style="max-width: 240px"> |
|
| 1482 |
- </div> |
|
| 1483 |
- <div class="table-wrap"> |
|
| 1484 |
- <table> |
|
| 1485 |
- <thead> |
|
| 1486 |
- <tr> |
|
| 1487 |
- <th style="width: 120px">ID</th> |
|
| 1488 |
- <th style="width: 130px">hosts_ip</th> |
|
| 1489 |
- <th style="width: 130px">dns_ip</th> |
|
| 1490 |
- <th>Names</th> |
|
| 1491 |
- <th style="width: 150px">Roles</th> |
|
| 1492 |
- <th style="width: 110px">Monitoring</th> |
|
| 1493 |
- <th style="width: 90px">Status</th> |
|
| 1494 |
- </tr> |
|
| 1495 |
- </thead> |
|
| 1496 |
- <tbody id="hosts"></tbody> |
|
| 1497 |
- </table> |
|
| 1498 |
- </div> |
|
| 1583 |
+ <section class="page" id="page-ca" data-page="ca" hidden> |
|
| 1584 |
+ <section class="panel"> |
|
| 1585 |
+ <div class="panel-head"> |
|
| 1586 |
+ <h2>Local Certificate Authority</h2> |
|
| 1587 |
+ <a class="linkbtn" href="/download/ca.crt">ca.crt</a> |
|
| 1588 |
+ </div> |
|
| 1589 |
+ <div class="problems" id="ca-status"></div> |
|
| 1590 |
+ </section> |
|
| 1591 |
+ <section class="panel"> |
|
| 1592 |
+ <div class="panel-head"> |
|
| 1593 |
+ <h2>Issued Certificates</h2> |
|
| 1594 |
+ <div class="stats" id="ca-certs-summary"></div> |
|
| 1595 |
+ </div> |
|
| 1596 |
+ <div class="table-wrap"> |
|
| 1597 |
+ <table> |
|
| 1598 |
+ <thead> |
|
| 1599 |
+ <tr> |
|
| 1600 |
+ <th style="width: 150px">Name</th> |
|
| 1601 |
+ <th>DNS names</th> |
|
| 1602 |
+ <th style="width: 210px">Validity</th> |
|
| 1603 |
+ <th style="width: 180px">Serial</th> |
|
| 1604 |
+ <th>Fingerprint</th> |
|
| 1605 |
+ <th style="width: 110px">Download</th> |
|
| 1606 |
+ </tr> |
|
| 1607 |
+ </thead> |
|
| 1608 |
+ <tbody id="ca-certs"></tbody> |
|
| 1609 |
+ </table> |
|
| 1610 |
+ </div> |
|
| 1611 |
+ </section> |
|
| 1499 | 1612 |
</section> |
| 1613 |
+ </main> |
|
| 1500 | 1614 |
|
| 1501 |
- <section class="panel"> |
|
| 1502 |
- <div class="panel-head"> |
|
| 1503 |
- <h2>Edit host</h2> |
|
| 1615 |
+ <div id="host-modal" class="modal-backdrop" hidden> |
|
| 1616 |
+ <section class="modal" role="dialog" aria-modal="true" aria-labelledby="host-modal-title"> |
|
| 1617 |
+ <div class="modal-head"> |
|
| 1618 |
+ <h2 id="host-modal-title">Edit host</h2> |
|
| 1619 |
+ <button type="button" id="close-host-modal" class="modal-close" aria-label="Close host editor">x</button> |
|
| 1504 | 1620 |
</div> |
| 1505 | 1621 |
<form id="host-form" class="grid"> |
| 1506 | 1622 |
<label>ID<input name="id" required></label> |
@@ -1512,13 +1628,13 @@ sub app_html {
|
||
| 1512 | 1628 |
<label>Sources<input name="sources"></label> |
| 1513 | 1629 |
<label>Monitoring<select name="monitoring"><option>pending</option><option>enabled</option><option>disabled</option></select></label> |
| 1514 | 1630 |
<label>Notes<input name="notes"></label> |
| 1515 |
- <div class="span2"> |
|
| 1631 |
+ <div class="span2 form-actions"> |
|
| 1516 | 1632 |
<button class="primary" type="submit">Save host</button> |
| 1517 | 1633 |
<button class="danger" type="button" id="delete-host">Delete host</button> |
| 1518 | 1634 |
</div> |
| 1519 | 1635 |
</form> |
| 1520 | 1636 |
</section> |
| 1521 |
- </main> |
|
| 1637 |
+ </div> |
|
| 1522 | 1638 |
</div> |
| 1523 | 1639 |
|
| 1524 | 1640 |
<div class="build-badge" title="Running build __HOST_MANAGER_BUILD_TITLE__">build __HOST_MANAGER_BUILD__</div> |
@@ -1528,6 +1644,14 @@ sub app_html {
|
||
| 1528 | 1644 |
|
| 1529 | 1645 |
const $ = (id) => document.getElementById(id); |
| 1530 | 1646 |
const msg = (text) => { $('message').textContent = text || ''; };
|
| 1647 |
+ const PAGE_PATHS = {
|
|
| 1648 |
+ '/': 'overview', |
|
| 1649 |
+ '/overview': 'overview', |
|
| 1650 |
+ '/hosts': 'hosts', |
|
| 1651 |
+ '/dns': 'dns', |
|
| 1652 |
+ '/work-orders': 'work-orders', |
|
| 1653 |
+ '/ca': 'ca', |
|
| 1654 |
+ }; |
|
| 1531 | 1655 |
|
| 1532 | 1656 |
async function api(path, options = {}) {
|
| 1533 | 1657 |
const res = await fetch(path, options); |
@@ -1536,6 +1660,25 @@ sub app_html {
|
||
| 1536 | 1660 |
return body; |
| 1537 | 1661 |
} |
| 1538 | 1662 |
|
| 1663 |
+ function currentPage() {
|
|
| 1664 |
+ return PAGE_PATHS[window.location.pathname] || 'overview'; |
|
| 1665 |
+ } |
|
| 1666 |
+ |
|
| 1667 |
+ function showPage(page, push = false) {
|
|
| 1668 |
+ const target = page || 'overview'; |
|
| 1669 |
+ document.querySelectorAll('[data-page]').forEach(section => {
|
|
| 1670 |
+ section.hidden = section.dataset.page !== target; |
|
| 1671 |
+ }); |
|
| 1672 |
+ document.querySelectorAll('[data-page-link]').forEach(link => {
|
|
| 1673 |
+ link.classList.toggle('active', link.dataset.pageLink === target);
|
|
| 1674 |
+ link.setAttribute('aria-current', link.dataset.pageLink === target ? 'page' : 'false');
|
|
| 1675 |
+ }); |
|
| 1676 |
+ if (push) {
|
|
| 1677 |
+ const href = target === 'overview' ? '/overview' : '/' + target; |
|
| 1678 |
+ history.pushState({ page: target }, '', href);
|
|
| 1679 |
+ } |
|
| 1680 |
+ } |
|
| 1681 |
+ |
|
| 1539 | 1682 |
function showLogin(errorText) {
|
| 1540 | 1683 |
document.body.classList.remove('is-app');
|
| 1541 | 1684 |
document.body.classList.add('is-login');
|
@@ -1550,6 +1693,7 @@ sub app_html {
|
||
| 1550 | 1693 |
document.body.classList.add('is-app');
|
| 1551 | 1694 |
$('login-screen').style.display = 'none';
|
| 1552 | 1695 |
$('app').style.display = 'block';
|
| 1696 |
+ showPage(currentPage()); |
|
| 1553 | 1697 |
} |
| 1554 | 1698 |
|
| 1555 | 1699 |
async function refresh() {
|
@@ -1585,21 +1729,80 @@ sub app_html {
|
||
| 1585 | 1729 |
const status = await api('/api/ca/status');
|
| 1586 | 1730 |
if (!status.initialized) {
|
| 1587 | 1731 |
$('ca-status').innerHTML = '<div class="problem"><strong>not initialized</strong> Run <code>sudo scripts/ca_manager.sh init</code> on jumper.</div>';
|
| 1732 |
+ $('ca-certs-summary').innerHTML = '';
|
|
| 1733 |
+ $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">CA is not initialized.</td></tr>';
|
|
| 1588 | 1734 |
return; |
| 1589 | 1735 |
} |
| 1590 | 1736 |
const certs = await api('/api/ca/certificates');
|
| 1737 |
+ const caDays = daysUntil(status.not_after); |
|
| 1591 | 1738 |
$('ca-status').innerHTML = `
|
| 1592 |
- <div class="muted" style="display:grid;gap:6px"> |
|
| 1739 |
+ <div class="muted ca-detail"> |
|
| 1593 | 1740 |
<div><strong>${escapeHtml(status.subject || '')}</strong></div>
|
| 1594 |
- <div>SHA256 ${escapeHtml(status.fingerprint_sha256 || '')}</div>
|
|
| 1741 |
+ <div>SHA256 <span class="mono ca-fingerprint">${escapeHtml(status.fingerprint_sha256 || '')}</span></div>
|
|
| 1595 | 1742 |
<div>valid ${escapeHtml(status.not_before || '')} - ${escapeHtml(status.not_after || '')}</div>
|
| 1596 |
- <div>${certs.length} issued certificate(s)</div>
|
|
| 1743 |
+ <div> |
|
| 1744 |
+ <span class="pill ${certStatusClass(caDays)}">${escapeHtml(certStatusLabel(caDays))}</span>
|
|
| 1745 |
+ <span>${certs.length} issued certificate(s)</span>
|
|
| 1746 |
+ </div> |
|
| 1597 | 1747 |
</div>`; |
| 1748 |
+ $('ca-certs-summary').innerHTML = [
|
|
| 1749 |
+ ['issued', certs.length], |
|
| 1750 |
+ ['expiring', certs.filter(cert => {
|
|
| 1751 |
+ const days = daysUntil(cert.not_after); |
|
| 1752 |
+ return days !== null && days >= 0 && days <= 30; |
|
| 1753 |
+ }).length], |
|
| 1754 |
+ ['expired', certs.filter(cert => {
|
|
| 1755 |
+ const days = daysUntil(cert.not_after); |
|
| 1756 |
+ return days !== null && days < 0; |
|
| 1757 |
+ }).length], |
|
| 1758 |
+ ].map(([k, v]) => `<span class="stat">${k}: ${escapeHtml(String(v))}</span>`).join('');
|
|
| 1759 |
+ $('ca-certs').innerHTML = certs.length ? certs.map(cert => {
|
|
| 1760 |
+ const days = daysUntil(cert.not_after); |
|
| 1761 |
+ const dnsNames = cert.dns_names || []; |
|
| 1762 |
+ const dnsHtml = dnsNames.length |
|
| 1763 |
+ ? dnsNames.map(name => `<span class="pill">${escapeHtml(name)}</span>`).join('')
|
|
| 1764 |
+ : '<span class="muted">No DNS SANs reported.</span>'; |
|
| 1765 |
+ return `<tr> |
|
| 1766 |
+ <td><strong>${escapeHtml(cert.name || '')}</strong></td>
|
|
| 1767 |
+ <td>${dnsHtml}</td>
|
|
| 1768 |
+ <td> |
|
| 1769 |
+ <div class="ca-detail"> |
|
| 1770 |
+ <span class="pill ${certStatusClass(days)}">${escapeHtml(certStatusLabel(days))}</span>
|
|
| 1771 |
+ <span class="muted">until ${escapeHtml(cert.not_after || '')}</span>
|
|
| 1772 |
+ </div> |
|
| 1773 |
+ </td> |
|
| 1774 |
+ <td class="mono">${escapeHtml(cert.serial || '')}</td>
|
|
| 1775 |
+ <td class="mono ca-fingerprint">${escapeHtml(cert.fingerprint_sha256 || '')}</td>
|
|
| 1776 |
+ <td><a class="linkbtn" href="/download/ca/cert/${encodeURIComponent(cert.name || '')}.crt">crt</a></td>
|
|
| 1777 |
+ </tr>`; |
|
| 1778 |
+ }).join('') : '<tr><td colspan="6" class="muted">No issued certificates.</td></tr>';
|
|
| 1598 | 1779 |
} catch (e) {
|
| 1599 | 1780 |
$('ca-status').innerHTML = `<div class="problem"><strong>CA status unavailable</strong> ${escapeHtml(e.message)}</div>`;
|
| 1781 |
+ $('ca-certs-summary').innerHTML = '';
|
|
| 1782 |
+ $('ca-certs').innerHTML = '<tr><td colspan="6" class="muted">Certificate list unavailable.</td></tr>';
|
|
| 1600 | 1783 |
} |
| 1601 | 1784 |
} |
| 1602 | 1785 |
|
| 1786 |
+ function daysUntil(dateText) {
|
|
| 1787 |
+ const time = Date.parse(dateText || ''); |
|
| 1788 |
+ if (!Number.isFinite(time)) return null; |
|
| 1789 |
+ return Math.ceil((time - Date.now()) / 86400000); |
|
| 1790 |
+ } |
|
| 1791 |
+ |
|
| 1792 |
+ function certStatusClass(days) {
|
|
| 1793 |
+ if (days === null) return ''; |
|
| 1794 |
+ if (days < 0) return 'bad'; |
|
| 1795 |
+ if (days <= 30) return 'warn'; |
|
| 1796 |
+ return 'ok'; |
|
| 1797 |
+ } |
|
| 1798 |
+ |
|
| 1799 |
+ function certStatusLabel(days) {
|
|
| 1800 |
+ if (days === null) return 'validity unknown'; |
|
| 1801 |
+ if (days < 0) return 'expired'; |
|
| 1802 |
+ if (days === 0) return 'expires today'; |
|
| 1803 |
+ return `${days}d remaining`;
|
|
| 1804 |
+ } |
|
| 1805 |
+ |
|
| 1603 | 1806 |
async function renderWorkOrders() {
|
| 1604 | 1807 |
try {
|
| 1605 | 1808 |
const data = await api('/api/work-orders');
|
@@ -1706,7 +1909,27 @@ sub app_html {
|
||
| 1706 | 1909 |
form.elements.names.value = (host.names || []).join('\n');
|
| 1707 | 1910 |
form.elements.roles.value = (host.roles || []).join(' ');
|
| 1708 | 1911 |
form.elements.sources.value = (host.sources || []).join(' ');
|
| 1709 |
- form.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
| 1912 |
+ openHostModal('Edit host');
|
|
| 1913 |
+ } |
|
| 1914 |
+ |
|
| 1915 |
+ function newHost() {
|
|
| 1916 |
+ const form = $('host-form');
|
|
| 1917 |
+ form.reset(); |
|
| 1918 |
+ form.elements.status.value = 'active'; |
|
| 1919 |
+ form.elements.monitoring.value = 'pending'; |
|
| 1920 |
+ openHostModal('New host');
|
|
| 1921 |
+ } |
|
| 1922 |
+ |
|
| 1923 |
+ function openHostModal(title) {
|
|
| 1924 |
+ $('host-modal-title').textContent = title || 'Edit host';
|
|
| 1925 |
+ $('host-modal').hidden = false;
|
|
| 1926 |
+ document.body.style.overflow = 'hidden'; |
|
| 1927 |
+ $('host-form').elements.id.focus();
|
|
| 1928 |
+ } |
|
| 1929 |
+ |
|
| 1930 |
+ function closeHostModal() {
|
|
| 1931 |
+ $('host-modal').hidden = true;
|
|
| 1932 |
+ document.body.style.overflow = ''; |
|
| 1710 | 1933 |
} |
| 1711 | 1934 |
|
| 1712 | 1935 |
function formObject(form) {
|
@@ -1714,6 +1937,7 @@ sub app_html {
|
||
| 1714 | 1937 |
} |
| 1715 | 1938 |
|
| 1716 | 1939 |
function escapeHtml(value) {
|
| 1940 |
+ value = value == null ? '' : String(value); |
|
| 1717 | 1941 |
return value.replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
| 1718 | 1942 |
} |
| 1719 | 1943 |
|
@@ -1807,6 +2031,15 @@ sub app_html {
|
||
| 1807 | 2031 |
otpAutofill.addEventListener('change', handleAutofill);
|
| 1808 | 2032 |
} |
| 1809 | 2033 |
|
| 2034 |
+ document.querySelectorAll('[data-page-link]').forEach(link => {
|
|
| 2035 |
+ link.addEventListener('click', (event) => {
|
|
| 2036 |
+ event.preventDefault(); |
|
| 2037 |
+ showPage(link.dataset.pageLink, true); |
|
| 2038 |
+ }); |
|
| 2039 |
+ }); |
|
| 2040 |
+ |
|
| 2041 |
+ window.addEventListener('popstate', () => showPage(currentPage()));
|
|
| 2042 |
+ |
|
| 1810 | 2043 |
$('login-form').addEventListener('submit', async (event) => {
|
| 1811 | 2044 |
event.preventDefault(); |
| 1812 | 2045 |
if (!otpReady()) return; |
@@ -1824,17 +2057,25 @@ sub app_html {
|
||
| 1824 | 2057 |
|
| 1825 | 2058 |
$('logout').addEventListener('click', async () => {
|
| 1826 | 2059 |
await api('/api/logout', { method: 'POST' }).catch(() => {});
|
| 1827 |
- clearOtp(); |
|
| 1828 |
- showLogin(); |
|
| 2060 |
+ window.location.replace('/?logged_out=' + Date.now());
|
|
| 1829 | 2061 |
}); |
| 1830 | 2062 |
|
| 1831 | 2063 |
$('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
|
| 1832 | 2064 |
$('filter').addEventListener('input', renderHosts);
|
| 2065 |
+ $('new-host').addEventListener('click', newHost);
|
|
| 2066 |
+ $('close-host-modal').addEventListener('click', closeHostModal);
|
|
| 2067 |
+ $('host-modal').addEventListener('click', (event) => {
|
|
| 2068 |
+ if (event.target === $('host-modal')) closeHostModal();
|
|
| 2069 |
+ }); |
|
| 2070 |
+ window.addEventListener('keydown', (event) => {
|
|
| 2071 |
+ if (event.key === 'Escape' && !$('host-modal').hidden) closeHostModal();
|
|
| 2072 |
+ }); |
|
| 1833 | 2073 |
|
| 1834 | 2074 |
$('host-form').addEventListener('submit', async (event) => {
|
| 1835 | 2075 |
event.preventDefault(); |
| 1836 | 2076 |
try {
|
| 1837 | 2077 |
await api('/api/hosts/upsert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formObject(event.target)) });
|
| 2078 |
+ closeHostModal(); |
|
| 1838 | 2079 |
msg('host saved');
|
| 1839 | 2080 |
await refresh(); |
| 1840 | 2081 |
} catch (e) { msg(e.message); }
|
@@ -1846,6 +2087,7 @@ sub app_html {
|
||
| 1846 | 2087 |
try {
|
| 1847 | 2088 |
await api('/api/hosts/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
|
| 1848 | 2089 |
$('host-form').reset();
|
| 2090 |
+ closeHostModal(); |
|
| 1849 | 2091 |
msg('host deleted');
|
| 1850 | 2092 |
await refresh(); |
| 1851 | 2093 |
} catch (e) { msg(e.message); }
|