Update
release-tag / release-image (push) Successful in 2m45s

This commit is contained in:
2026-09-01 13:59:25 +02:00
parent c609c34f16
commit 6eb4e093ec
8 changed files with 1267 additions and 137 deletions
+12 -9
View File
@@ -216,12 +216,13 @@ async function renderSecurity(){
try{
const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{};
window.__securityCaps=c;
window.__firewallBackend=d.firewall_backend||{};
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('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Auto-detected native firewall management through UFW, firewalld or isolated nftables.',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>
@@ -236,17 +237,19 @@ async function renderSecurity(){
}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 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"',canConfigure=manage&&(key==='firewall'||installed);return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${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 dangerNotice"><b>Firewall conflict:</b> ${esc(conflicts.join(', '))}. Resolve competing active frontends before applying changes.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Install</button>`:''}<button class="btn" id="configure-${key}" ${!canConfigure?'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 securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`Install or upgrade ${component==='firewall'?provider: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',provider})});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)}}
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Auto detect':v}</option>`).join('')}
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Detected host firewall</h3><span class="tag">selected: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Available</small><b>${esc(available.join(', ')||'none')}</b></span><span><small>Active</small><b>${esc(active.join(', ')||'none')}</b></span><span><small>Default</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(b.reason)}</div>`:''}<div class="field"><label>Existing native rules/state <span class="muted">· read-only · foreign rules are preserved</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'No runtime rules reported.')}</pre></div></div>`}
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider model:</b> Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs <code>ufw reset</code> or <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch-managed rules</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Manage provider default inbound ${selected==='nftables'?'<span class="muted">(native policy is always local to Dockwatch table)</span>':''}</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> Allow ICMP / IPv6 ICMP ${selected!=='nftables'?'<span class="muted">(kept native by this provider)</span>':''}</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>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Dockwatch-managed 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. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>Install ${esc(selected)}</button>`:''}<button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Apply with rollback</button></div>`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#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(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&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==='accept'||!r.action?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option><option value="reject" ${r.action==='reject'?'selected':''}>REJECT</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMIT</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 fwUp" title="Move up">↑</button><button class="iconbtn fwDown" title="Move down">↓</button><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,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)}}}
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 Dockwatch-managed policy and provider default snapshot 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>`}