Stripped from the internal repo: per-box deploy trees, Go services (nodelist/astmeta/proxy/svxdr), deploy/sync scripts and all secrets. Config templates for nginx, PHP-FPM, Redis and tmpfiles live in install/ with CHANGEME placeholders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
155 lines
8.6 KiB
JavaScript
155 lines
8.6 KiB
JavaScript
'use strict';
|
|
var $ = function(id){ return document.getElementById(id); };
|
|
|
|
function setMsg(el, txt, err){
|
|
if (!el) return;
|
|
if (!txt){ el.style.display='none'; return; }
|
|
el.style.display='block'; el.textContent=txt; el.style.color=err?'#ff8080':'#00ff80';
|
|
}
|
|
function esc(s){ var d=document.createElement('div'); d.textContent=(s==null?'':String(s)); return d.innerHTML; }
|
|
|
|
async function rapi(body){
|
|
var r = await fetch('/auth/admin_reflector_api.php', {
|
|
method:'POST', credentials:'same-origin',
|
|
headers:{'Content-Type':'application/json','X-CSRF-Token':window._csrfToken||''},
|
|
body:JSON.stringify(body)
|
|
});
|
|
return r.json().catch(function(){ return null; });
|
|
}
|
|
|
|
function timeAgo(epoch){
|
|
if (!epoch) return '';
|
|
var diff = Math.floor(Date.now()/1000) - epoch;
|
|
var fut = diff < 0; if (fut) diff = -diff;
|
|
var t;
|
|
if (diff < 60) t = 'just now';
|
|
else if (diff < 3600) t = Math.floor(diff/60)+'m';
|
|
else if (diff < 86400) t = Math.floor(diff/3600)+'h';
|
|
else t = Math.floor(diff/86400)+'d';
|
|
if (t === 'just now') return t;
|
|
return fut ? ('in '+t) : (t+' ago');
|
|
}
|
|
function fmtDate(epoch){ if (!epoch) return ''; return new Date(epoch*1000).toISOString().slice(0,16).replace('T',' ') + ' UTC'; }
|
|
|
|
/* ---- icon buttons ---- */
|
|
var ICONS = {
|
|
info: '<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>',
|
|
trash: '<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6Z"/><path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1ZM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118ZM2.5 3h11V2h-11v1Z"/>',
|
|
repend: '<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/><path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>',
|
|
sign: '<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>',
|
|
reject: '<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>'
|
|
};
|
|
function iconBtn(action, cs, name, title, btnCls){
|
|
return '<button class="btn btn-sm '+btnCls+' act-'+action+'" data-cs="'+esc(cs)+'" title="'+title+'" aria-label="'+title+'" style="padding:2px 6px; line-height:1; margin-left:4px;">'
|
|
+ '<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" style="vertical-align:-2px;">'+ICONS[name]+'</svg></button>';
|
|
}
|
|
|
|
async function loadCerts(){
|
|
var body = $('certsBody');
|
|
body.innerHTML = '<tr><td colspan="3" class="text-secondary">Loading…</td></tr>';
|
|
var j = await rapi({action:'list_certs'});
|
|
if (!j || !j.ok){ body.innerHTML = '<tr><td colspan="3" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>'; return; }
|
|
var list = j.certs || [];
|
|
if (!list.length){ body.innerHTML = '<tr><td colspan="3" class="text-secondary">No issued certificates.</td></tr>'; return; }
|
|
body.innerHTML = list.map(function(it){
|
|
var cs = esc(it.callsign);
|
|
return '<tr><td class="mono"><strong>'+cs+'</strong></td>'
|
|
+ '<td class="text-secondary">'+esc(fmtDate(it.mtime))+'</td>'
|
|
+ '<td style="text-align:right; white-space:nowrap;">'
|
|
+ iconBtn('info', it.callsign, 'info', 'Certificate info', 'btn-outline-secondary')
|
|
+ iconBtn('repend', it.callsign, 'repend', 'Move back to pending', 'btn-outline-secondary')
|
|
+ iconBtn('del', it.callsign, 'trash', 'Delete / revoke', 'btn-outline-danger')
|
|
+ '</td></tr>';
|
|
}).join('');
|
|
body.querySelectorAll('.act-info').forEach(function(b){ b.addEventListener('click', function(){ certInfo(b.dataset.cs); }); });
|
|
body.querySelectorAll('.act-repend').forEach(function(b){ b.addEventListener('click', function(){ repend(b.dataset.cs); }); });
|
|
body.querySelectorAll('.act-del').forEach(function(b){ b.addEventListener('click', function(){ delCert(b.dataset.cs); }); });
|
|
}
|
|
|
|
async function loadPending(){
|
|
var body = $('pendingBody');
|
|
body.innerHTML = '<tr><td colspan="4" class="text-secondary">Loading…</td></tr>';
|
|
var j = await rapi({action:'pending'});
|
|
if (!j || !j.ok){ body.innerHTML = '<tr><td colspan="4" style="color:#ff8080;">'+esc((j&&j.error)||'Failed to load')+'</td></tr>'; return; }
|
|
var list = j.pending || [];
|
|
if (!list.length){ body.innerHTML = '<tr><td colspan="4" class="text-secondary">No pending CSRs.</td></tr>'; return; }
|
|
body.innerHTML = list.map(function(it){
|
|
var cs = esc(it.callsign);
|
|
return '<tr><td class="mono"><strong>'+cs+'</strong></td>'
|
|
+ '<td class="text-secondary">'+esc(timeAgo(it.mtime))+'</td>'
|
|
+ '<td class="mono small">'+esc(it.subject||'')+'</td>'
|
|
+ '<td style="text-align:right; white-space:nowrap;">'
|
|
+ iconBtn('sign', it.callsign, 'sign', 'Sign', 'btn-success')
|
|
+ iconBtn('reject', it.callsign, 'reject', 'Reject', 'btn-outline-danger')
|
|
+ '</td></tr>';
|
|
}).join('');
|
|
body.querySelectorAll('.act-sign').forEach(function(b){ b.addEventListener('click', function(){ signCsr(b.dataset.cs); }); });
|
|
body.querySelectorAll('.act-reject').forEach(function(b){ b.addEventListener('click', function(){ rejectCsr(b.dataset.cs); }); });
|
|
}
|
|
|
|
async function signCsr(cs){
|
|
if (!confirm('Sign certificate for '+cs+'?')) return;
|
|
setMsg($('certMsg'), null);
|
|
var j = await rapi({action:'sign', callsign:cs});
|
|
if (j && j.ok){ setMsg($('certMsg'), j.message||('Signed '+cs), false); setTimeout(loadPending, 1500); }
|
|
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
|
|
}
|
|
async function rejectCsr(cs){
|
|
if (!confirm('Reject (delete) pending CSR for '+cs+'?')) return;
|
|
setMsg($('certMsg'), null);
|
|
var j = await rapi({action:'rmpending', callsign:cs});
|
|
if (j && j.ok){ setMsg($('certMsg'), j.message||('Rejected '+cs), false); loadPending(); }
|
|
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
|
|
}
|
|
async function delCert(cs){
|
|
if (!confirm('Delete/revoke certificate for '+cs+'?\nThe node will no longer be able to connect.')) return;
|
|
setMsg($('certMsg'), null);
|
|
var j = await rapi({action:'revoke', callsign:cs});
|
|
if (j && j.ok){ setMsg($('certMsg'), j.message||('Revoked '+cs), false); loadCerts(); }
|
|
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
|
|
}
|
|
async function repend(cs){
|
|
if (!confirm('Move '+cs+' back to pending for re-signing?')) return;
|
|
setMsg($('certMsg'), null);
|
|
var j = await rapi({action:'repend', callsign:cs});
|
|
if (j && j.ok){ setMsg($('certMsg'), j.message||('Re-pended '+cs), false); }
|
|
else setMsg($('certMsg'), (j&&j.error)||'Failed', true);
|
|
}
|
|
async function certInfo(cs){
|
|
var j = await rapi({action:'cert_info', callsign:cs});
|
|
if (!j || !j.ok){ setMsg($('certMsg'), (j&&j.error)||'Failed', true); return; }
|
|
var i = j.info || {};
|
|
var expired = i.validTo && (i.validTo*1000 < Date.now());
|
|
var validTo = fmtDate(i.validTo) + (i.validTo ? ' (' + (expired ? 'EXPIRED' : 'expires '+timeAgo(i.validTo)) + ')' : '');
|
|
var rows = [
|
|
['Callsign', i.callsign],
|
|
['Subject CN', i.subjectCN],
|
|
['Issuer', i.issuer],
|
|
['Serial', i.serial],
|
|
['Valid from', fmtDate(i.validFrom)],
|
|
['Valid to', validTo],
|
|
['SHA-256', i.fingerprint]
|
|
];
|
|
$('certInfoTitle').textContent = 'Certificate · ' + (i.callsign || cs);
|
|
$('certInfoBody').innerHTML = rows.map(function(r){
|
|
var v = esc(r[1] || '—');
|
|
if (r[0] === 'Valid to' && expired) v = '<span style="color:#ff8080;">'+v+'</span>';
|
|
return '<div style="margin-bottom:6px;"><span class="text-secondary" style="display:inline-block;min-width:92px;">'+esc(r[0])+':</span> '+v+'</div>';
|
|
}).join('');
|
|
bootstrap.Modal.getOrCreateInstance($('certInfoModal')).show();
|
|
}
|
|
|
|
function switchView(v){
|
|
$('viewCerts').style.display = (v === 'certs') ? '' : 'none';
|
|
$('viewPending').style.display = (v === 'pending') ? '' : 'none';
|
|
if (v === 'pending') loadPending(); else loadCerts();
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
fetch('/auth/me.php', {credentials:'same-origin'})
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(j){ if (j && j.csrf_token) window._csrfToken = j.csrf_token; loadCerts(); })
|
|
.catch(function(){ loadCerts(); });
|
|
$('certView').addEventListener('change', function(){ switchView(this.value); });
|
|
});
|