v9.4.0
This commit is contained in:
+52
-2
@@ -23,12 +23,12 @@ function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/
|
||||
function setCrumb(t){$('#crumb').textContent=t}
|
||||
function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote'):'Local Docker'}
|
||||
async function init(){applyPreferences();state.me=await api('/api/me');const [sysR,nodesR]=await Promise.allSettled([api('/api/system'),api('/api/nodes')]);state.system=sysR.status==='fulfilled'?sysR.value:null;state.nodes=nodesR.status==='fulfilled'&&Array.isArray(nodesR.value)?nodesR.value:[];renderUser();renderNodePicker();wireShell();await refreshData(true);navigate('dashboard')}
|
||||
function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
|
||||
function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
|
||||
function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`<option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
|
||||
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
|
||||
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
|
||||
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitors: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Services: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}}
|
||||
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,activity:renderActivity}[state.view]||renderDashboard)()}
|
||||
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,activity:renderActivity}[state.view]||renderDashboard)()}
|
||||
function pageHead(title,sub,actions=''){return `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
|
||||
function renderDashboard(){setCrumb('Dashboard');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Dashboard',nodeName()+' · Docker & Uptime overview','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Refresh</button>')}<div class="stats"><div class="stat"><small>Compose stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} running</span></div></div><div class="stat"><small>Monitors up</small><strong>${up}</strong><div class="trend">${state.monitors.length} configured</div></div><div class="stat"><small>Monitor incidents</small><strong class="${down?'red':''}">${down}</strong><div class="trend">current probe failures</div></div><div class="stat"><small>Services</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} degraded</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose stacks</h2><button class="btn tiny" id="goStacks">View all</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime monitors</h2><button class="btn tiny" id="goMons">View all</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
|
||||
function stackTable(items){if(!items.length)return'<div class="empty">No compose stacks found.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Services</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
|
||||
@@ -198,6 +198,56 @@ async function renderNotifications(){setCrumb('Observability / Notifications');$
|
||||
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Topic','topic')+f('nToken','Access token','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App token','token',true);return f('nHost','SMTP host','host')+f('nPort','Port','port',false,'587')+`<div class="field"><label>Security</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">None</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP authentication</label>`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> Disable TLS certificate verification (unsafe)</label>`}
|
||||
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} notification provider</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Type</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Enabled</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="nSave">Save</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
|
||||
|
||||
|
||||
|
||||
async function renderSecurity(){
|
||||
setCrumb(`System / Host Security / ${nodeName()}`);
|
||||
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host Security','Administrator access required.')}<div class="empty">Host security configuration is restricted to administrators.</div>`; return }
|
||||
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}<div class="panel"><div class="empty">Auditing host security posture…</div></div>`;
|
||||
$('#securityRefresh').onclick=renderSecurity;
|
||||
try{
|
||||
const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{};
|
||||
window.__securityCaps=c;
|
||||
const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit';
|
||||
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}
|
||||
<div class="securityHero ${capClass}"><div><small>HOST SECURITY POSTURE</small><div class="securityScore">${Number(d.score||0)}<span>/100</span></div><p>${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}</p></div><div class="securityCaps">${securityCapabilityPills(c)}</div></div>
|
||||
${c.reason?`<div class="notice ${c.enabled?'':'warnNotice'}"><b>Capability:</b> ${esc(c.reason)}</div>`:''}
|
||||
<div class="securityGrid">
|
||||
${securityComponentCard('firewall','Firewall · nftables','Isolated host INPUT policy. Docker NAT/FORWARD chains remain untouched.',d.firewall,managed.firewall,d.conflicts)}
|
||||
${securityComponentCard('fail2ban','Fail2Ban','Rate-limit and ban repeated authentication failures using managed jail.d overrides.',d.fail2ban,managed.fail2ban)}
|
||||
${securityComponentCard('auditd','Linux Audit · auditd','Track changes to identity, SSH, sudo, Docker and selected host paths.',d.auditd,managed.auditd)}
|
||||
</div>
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Security findings</h2><span class="muted">Dockwatch-managed posture only; not a substitute for a full host benchmark.</span></div>${securityFindings(d.findings||[])}</div>
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Safety model</h2></div><div class="securitySafety"><div><b>Audit first</b><p>Inspection can be enabled independently from host mutations.</p></div><div><b>Explicit mutation opt-in</b><p>Configuration and package installation are separate capabilities.</p></div><div><b>Managed drop-ins</b><p>Fail2Ban and auditd use dedicated Dockwatch files; foreign configuration is preserved.</p></div><div><b>Audited changes</b><p>Every mutation passes the existing Dockwatch admin RBAC, origin guard and Activity audit trail.</p></div></div></div>`;
|
||||
$('#securityRefresh').onclick=renderSecurity;
|
||||
$$('[data-secinstall]').forEach(b=>b.onclick=()=>securityInstall(b.dataset.secinstall,b));
|
||||
$$('[data-secaction]').forEach(b=>b.onclick=()=>securityComponentAction(b.dataset.seccomponent,b.dataset.secaction,b));
|
||||
$('#configure-firewall')?.addEventListener('click',securityFirewallModal);
|
||||
$('#configure-fail2ban')?.addEventListener('click',securityFail2BanModal);
|
||||
$('#configure-auditd')?.addEventListener('click',securityAuditdModal);
|
||||
}catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}<div class="empty red">${esc(e.message)}</div>`}
|
||||
}
|
||||
function securityCapabilityPills(c){return `<span class="tag ${c.host_root_available?'oktag':''}">host root ${c.host_root_available?'✓':'×'}</span><span class="tag ${c.target_verified?'oktag':''}">host namespace ${c.target_verified?'✓':'×'}</span><span class="tag ${c.allow_changes?'oktag':''}">${c.allow_changes?'manage':'audit only'}</span><span class="tag ${c.allow_package_management?'oktag':''}">packages ${c.allow_package_management?'enabled':'locked'}</span>`}
|
||||
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"';return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${esc(title)}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installed</small><b>${installed?'Yes':'No'}</b></span><span><small>Runtime</small><b class="${active?'green':'muted'}">${active?'Active':'Inactive'}</b></span><span><small>Boot</small><b>${st.enabled?'Enabled':'—'}</b></span><span><small>Config</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice warnNotice"><b>Conflict:</b> ${esc(conflicts.join(', '))} active. Dockwatch firewall apply is blocked.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Install</button>`:''}<button class="btn" id="configure-${key}" ${!installed||!manage?'disabled':''} ${!manage?'title="Host security changes are disabled for this environment"':''}>Configure</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Enable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Disable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Restart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Reload</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Uses the host package manager to install the currently available package version">Upgrade</button>`:''}</div></section>`}
|
||||
function securityFindings(rows){if(!rows.length)return '<div class="empty">No findings.</div>';return `<div class="securityFindings">${rows.map(f=>`<div class="securityFinding sev-${esc(f.severity)}"><span>${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}</span><div><b>${esc(f.title)}</b><p>${esc(f.detail)}</p>${f.action?`<small>${esc(f.action)}</small>`:''}</div></div>`).join('')}</div>`}
|
||||
async function securityInstall(component,btn){if(!confirm(`Install or upgrade ${component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall'})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode());firewallEditor(p)}catch(e){toast(e.message)}}
|
||||
function firewallEditor(p){const rules=asArray(p.rules);modal(`<div class="modalhead"><h2>Managed nftables firewall</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Scope:</b> Dockwatch manages only <code>table inet dockwatch</code> and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.</div><div class="fieldgrid" style="margin-top:12px"><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch firewall policy</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div><label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''}> Allow ICMP / IPv6 ICMP</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. You must explicitly keep the rules after confirming that this UI is still reachable. Default DROP requires that you add the management ports/CIDRs you need.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview nftables</button><button class="btn danger" id="fwApply">Apply with rollback</button></div>`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview('nftables preview',x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy();if(policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action!=='drop'?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 or 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Source CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Comment" value="${esc(r.comment||'')}"><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
|
||||
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove())}
|
||||
function collectFirewallPolicy(){return {enabled:$('#fwEnabled').checked,default_inbound:$('#fwDefault').value,allow_icmp:$('#fwICMP').checked,trusted_cidrs:$('#fwTrusted').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
|
||||
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${warnings.map(x=>`<div class="notice">${esc(x)}</div>`).join('')}${conflicts?.length?`<div class="notice dangerNotice">Conflicts: ${esc(conflicts.join(', '))}</div>`:''}<pre class="terminal" style="max-height:55vh">${esc(text||'')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
|
||||
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall applied · verification window</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Do not close this dialog yet.</b> If the new policy breaks access, Dockwatch will restore the previous managed firewall policy automatically.</div><div class="securityCountdown"><small>Automatic rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Verify SSH and any other management path in a separate session. Then keep the change.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Rollback now</button><button class="btn primary" id="fwCommitNow">Keep changes</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
|
||||
async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}}
|
||||
function fail2banEditor(p){modal(`<div class="modalhead"><h2>Fail2Ban policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/fail2ban/jail.d/dockwatch.local</code>. Other distro/user jails are preserved and remain effective.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Ban time</label><input id="f2bBan" value="${esc(p.bantime||'1h')}"></div><div class="field"><label>Find time</label><input id="f2bFind" value="${esc(p.findtime||'10m')}"></div><div class="field"><label>Max retry</label><input id="f2bRetry" type="number" min="1" max="1000" value="${esc(p.maxretry||5)}"></div><div class="field"><label>Backend</label><select id="f2bBackend"><option>auto</option><option>systemd</option><option>polling</option><option>pyinotify</option></select></div><div class="field full"><label>Ignore IP/CIDR · one per line</label><textarea id="f2bIgnore">${esc(asArray(p.ignore_ip).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Jails</h3><button class="btn tiny" id="f2bAdd">+ Jail</button></div><div id="f2bJails">${asArray(p.jails).map(fail2banJailRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="f2bSave">Validate & apply</button></div>`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban applied');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function fail2banJailRow(j={}){return `<div class="f2bJail securityFormRow"><label class="switch"><input class="f2bEnabled" type="checkbox" ${j.enabled!==false?'checked':''}> enabled</label><input class="f2bName" placeholder="jail name" value="${esc(j.name||'')}"><input class="f2bPort" placeholder="port · ssh" value="${esc(j.port||'')}"><input class="f2bFilter" placeholder="filter" value="${esc(j.filter||'')}"><select class="f2bBackend"><option ${j.backend==='auto'||!j.backend?'selected':''}>auto</option><option ${j.backend==='systemd'?'selected':''}>systemd</option><option ${j.backend==='polling'?'selected':''}>polling</option><option ${j.backend==='pyinotify'?'selected':''}>pyinotify</option></select><input class="f2bLog" placeholder="log path · optional" value="${esc(j.logpath||'')}"><input class="f2bMax" type="number" min="0" max="1000" placeholder="retries" value="${esc(j.maxretry||'')}"><button class="iconbtn f2bRemove">×</button></div>`}
|
||||
function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())}
|
||||
async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}}
|
||||
function auditdEditor(p){const check=(id,label,key)=>`<label class="switch"><input id="${id}" type="checkbox" ${p[key]?'checked':''}> ${label}</label>`;modal(`<div class="modalhead"><h2>Linux Audit policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/audit/rules.d/90-dockwatch.rules</code> and loads it through <code>augenrules</code>. Presets use file watches rather than broad syscall rules.</div><div class="securityChecks" style="margin-top:12px">${check('audIdentity','Identity files (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH server configuration','ssh')}${check('audDocker','Docker socket and daemon configuration','docker')}${check('audSystemd','systemd unit configuration','systemd')}${check('audModules','Kernel module configuration','kernel_modules')}</div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Custom file watches</h3><button class="btn tiny" id="audAdd">+ Watch</button></div><div id="audWatches">${asArray(p.custom).map(auditWatchRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="audSave">Validate & load</button></div>`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Loading…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit rules loaded');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function auditWatchRow(w={}){return `<div class="audWatch securityFormRow audit"><input class="audPath" placeholder="/srv/app/config" value="${esc(w.path||'')}"><input class="audPerms" placeholder="wa" value="${esc(w.permissions||'wa')}"><input class="audKey" placeholder="audit-key" value="${esc(w.key||'')}"><button class="iconbtn audRemove">×</button></div>`}
|
||||
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
|
||||
|
||||
window.addEventListener('resize',terminalResize);
|
||||
function modal(html){$('#modalRoot').innerHTML=`<div class="modalback"><div class="modal" role="dialog" aria-modal="true">${html}</div></div>`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)}
|
||||
function closeModal(){$('#modalRoot').innerHTML=''}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<button data-view="notifications"><span>⌁</span>Notifications</button>
|
||||
<div class="navlabel">System</div>
|
||||
<button data-view="nodes"><span>◎</span>Environments</button>
|
||||
<button data-view="security"><span>⛨</span>Host Security</button>
|
||||
<button data-view="activity"><span>≋</span>Activity</button>
|
||||
</nav>
|
||||
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Logout">↪</button></div>
|
||||
|
||||
@@ -20,3 +20,8 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
|
||||
.serviceProbeList{max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:6px;background:var(--panel2)}.serviceProbeList .switch{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;padding:7px;border-radius:5px}.serviceProbeList .switch:hover{background:var(--panel3)}
|
||||
/* v9.2 host/container identity diagnostics */
|
||||
.mono{font:11px/1.45 "Cascadia Code","SFMono-Regular",Consolas,monospace}.warn{color:var(--amber)}.compactList{margin:8px 0 0;padding-left:18px;color:var(--muted)}.compactList li{margin:6px 0}.panel.inset{padding:12px;background:var(--panel2);overflow:visible}.panel.inset h3{margin:0;font-size:12px}.dangerNotice{border-color:#592733!important;background:#2b171e!important;color:#f1bcc5!important}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:7px}.tablewrap .table{min-width:720px}
|
||||
|
||||
/* Host Security layer */
|
||||
.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
@media(max-width:1300px){.securityGrid{grid-template-columns:1fr}.securityFacts{grid-template-columns:repeat(4,1fr)}}
|
||||
@media(max-width:860px){.securityHero{align-items:flex-start;flex-direction:column}.securityCaps{justify-content:flex-start}.securityFacts{grid-template-columns:repeat(2,1fr)}.securitySafety{grid-template-columns:1fr}.fwRule{grid-template-columns:1fr 1fr}.fwRule .fwPort,.fwRule .fwSource,.fwRule .fwComment{grid-column:span 2}.securityFormRow{grid-template-columns:1fr 1fr}.securityFormRow .f2bLog{grid-column:span 2}.securityFormRow.audit{grid-template-columns:1fr}.securityChecks{grid-template-columns:1fr}}
|
||||
|
||||
Reference in New Issue
Block a user