Procházet zdrojové kódy

feat(firewall): dynamic iptables chain dropdown and field adaptation

Proxmox Router před 3 dny
rodič
revize
907d069bb7
4 změnil soubory, kde provedl 551 přidání a 27 odebrání
  1. binární
      bin/debtik-cli
  2. binární
      bin/proxmox-router
  3. 495 19
      internal/web/static/app.js
  4. 56 8
      internal/web/static/index.html

binární
bin/debtik-cli


binární
bin/proxmox-router


+ 495 - 19
internal/web/static/app.js

@@ -4173,6 +4173,183 @@ function toggleMaximizeFilterModal() {
   }
 }
 
+const FW_NATIVE_CHAINS = {
+  filter: [
+    { id: 'forward', label: 'FORWARD (routed through router)' },
+    { id: 'input', label: 'INPUT (destined to this router)' },
+    { id: 'output', label: 'OUTPUT (generated by this router)' }
+  ],
+  nat: [
+    { id: 'postrouting', label: 'POSTROUTING (outgoing after routing — SNAT / MASQUERADE)' },
+    { id: 'prerouting', label: 'PREROUTING (incoming before routing — DNAT / REDIRECT)' },
+    { id: 'output', label: 'OUTPUT (locally generated — DNAT / REDIRECT)' },
+    { id: 'input', label: 'INPUT (destined to this machine — SNAT)' }
+  ],
+  mangle: [
+    { id: 'prerouting', label: 'PREROUTING (incoming before routing)' },
+    { id: 'input', label: 'INPUT (destined to this machine)' },
+    { id: 'forward', label: 'FORWARD (routed through router)' },
+    { id: 'output', label: 'OUTPUT (generated by this machine)' },
+    { id: 'postrouting', label: 'POSTROUTING (outgoing after routing)' }
+  ]
+};
+
+function populateChainDropdown(table, currentChain) {
+  const sel = document.getElementById(`${table}-modal-chain-select`);
+  if (!sel) return;
+  const cur = (currentChain || '').trim();
+  const lower = cur.toLowerCase();
+
+  const rulesKey = table === 'filter' ? 'filterRules' : table === 'nat' ? 'natRules' : 'mangleRules';
+  const rules = state[rulesKey] || [];
+  const nativeIds = FW_NATIVE_CHAINS[table].map(c => c.id);
+  const customChains = new Set();
+  rules.forEach(r => {
+    if (r.chain) {
+      const c = r.chain.trim();
+      const l = c.toLowerCase();
+      if (!nativeIds.includes(l) && l !== 'srcnat' && l !== 'dstnat' && !['forward', 'input', 'output', 'prerouting', 'postrouting'].includes(l)) {
+        customChains.add(c);
+      }
+    }
+  });
+
+  const isCurrentCustom = cur && !nativeIds.includes(lower) && lower !== 'srcnat' && lower !== 'dstnat' && !['forward', 'input', 'output', 'prerouting', 'postrouting'].includes(lower);
+  if (isCurrentCustom) {
+    customChains.add(cur);
+  }
+
+  let html = '';
+  FW_NATIVE_CHAINS[table].forEach(c => {
+    html += `<option value="${c.id}">${escapeHtml(c.label)}</option>`;
+  });
+  if (customChains.size > 0) {
+    html += `<optgroup label="Custom Chains">`;
+    customChains.forEach(c => {
+      html += `<option value="${escapeHtml(c)}">Chain: ${escapeHtml(c)} (custom)</option>`;
+    });
+    html += `</optgroup>`;
+  }
+  html += `<option value="__custom__">+ Custom Chain...</option>`;
+  sel.innerHTML = html;
+
+  if (isCurrentCustom) {
+    sel.value = cur;
+  } else if (nativeIds.includes(lower)) {
+    sel.value = lower;
+  } else if (table === 'nat' && (lower === 'srcnat' || lower === 'dstnat')) {
+    sel.value = lower === 'dstnat' ? 'prerouting' : 'postrouting';
+  } else {
+    sel.value = nativeIds[0];
+  }
+
+  const customWrap = document.getElementById(`${table}-modal-custom-wrap`);
+  const customInput = document.getElementById(`${table}-modal-custom-input`);
+  if (isCurrentCustom || sel.value === '__custom__') {
+    if (customWrap) customWrap.classList.remove('hidden');
+    if (customInput) customInput.value = cur;
+  } else {
+    if (customWrap) customWrap.classList.add('hidden');
+    if (customInput) customInput.value = '';
+  }
+
+  const chainInput = document.getElementById(`${table}-modal-chain`);
+  if (chainInput) chainInput.value = cur || sel.value;
+}
+
+function syncFilterChainFromSelect() {
+  const sel = document.getElementById('filter-modal-chain-select');
+  const customWrap = document.getElementById('filter-modal-custom-wrap');
+  const customInput = document.getElementById('filter-modal-custom-input');
+  const chainInput = document.getElementById('filter-modal-chain');
+  if (!sel || !chainInput) return;
+
+  if (sel.value === '__custom__') {
+    if (customWrap) customWrap.classList.remove('hidden');
+    if (customInput) {
+      customInput.value = '';
+      customInput.focus();
+    }
+    chainInput.value = '';
+  } else {
+    if (customWrap) customWrap.classList.add('hidden');
+    chainInput.value = sel.value;
+  }
+  handleFilterChainChange();
+}
+
+function syncFilterCustomChainInput() {
+  const customInput = document.getElementById('filter-modal-custom-input');
+  const chainInput = document.getElementById('filter-modal-chain');
+  if (customInput && chainInput) {
+    chainInput.value = customInput.value.trim();
+  }
+  handleFilterChainChange();
+}
+
+function handleFilterChainChange() {
+  const chainInput = document.getElementById('filter-modal-chain');
+  const chain = (chainInput?.value || 'forward').trim().toLowerCase();
+
+  const sel = document.getElementById('filter-modal-chain-select');
+  if (sel && sel.value !== chain && sel.value !== '__custom__') {
+    if (['forward', 'input', 'output'].includes(chain)) {
+      sel.value = chain;
+    }
+  }
+
+  const hintEl = document.getElementById('filter-chain-hint');
+  if (hintEl) {
+    if (chain === 'forward') {
+      hintEl.textContent = 'FORWARD: packets routed through this router (accepts both In and Out interfaces).';
+    } else if (chain === 'input') {
+      hintEl.textContent = 'INPUT: packets destined to this router (In-Interface only; Out-Interface is invalid in iptables).';
+    } else if (chain === 'output') {
+      hintEl.textContent = 'OUTPUT: packets generated by this router (Out-Interface only; In-Interface is invalid in iptables).';
+    } else {
+      hintEl.textContent = `Custom chain "${chain}": reached via an explicit JUMP from a built-in chain.`;
+    }
+  }
+
+  const rowIn = document.getElementById('fw-row-iniface');
+  const rowInList = document.getElementById('fw-row-inifacelist');
+  const rowOut = document.getElementById('fw-row-outiface');
+  const rowOutList = document.getElementById('fw-row-outifacelist');
+
+  if (chain === 'input') {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.add('hidden');
+    if (rowOutList) rowOutList.classList.add('hidden');
+  } else if (chain === 'output') {
+    if (rowIn) rowIn.classList.add('hidden');
+    if (rowInList) rowInList.classList.add('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  } else {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  }
+
+  checkFilterActionChainCompatibility();
+}
+
+function checkFilterActionChainCompatibility() {
+  const chain = (document.getElementById('filter-modal-chain')?.value || 'forward').trim().toLowerCase();
+  const warnBox = document.getElementById('filter-action-incompatible-warn');
+  const warnMsg = document.getElementById('filter-action-incompatible-msg');
+  if (!warnBox || !warnMsg) return;
+
+  if (chain === 'prerouting' || chain === 'postrouting') {
+    warnMsg.textContent = `Chain "${chain.toUpperCase()}" is not available in the Filter table. Filter only supports INPUT, FORWARD, OUTPUT or custom chains.`;
+    warnBox.classList.remove('hidden');
+  } else {
+    warnBox.classList.add('hidden');
+  }
+}
+
 function openAddFilterModal() {
   const tos = document.getElementById('fw-val-tos'); if (tos) tos.value = '';
   switchFwRuleTab('general');
@@ -4184,6 +4361,8 @@ function openAddFilterModal() {
   document.getElementById('filter-modal-enabled').checked = true;
   document.getElementById('filter-modal-comment').value = '';
   document.getElementById('filter-modal-chain').value = 'forward';
+  populateChainDropdown('filter', 'forward');
+  handleFilterChainChange();
   document.getElementById('filter-modal-action').value = 'accept';
   document.getElementById('filter-modal-log').checked = false;
 
@@ -4226,7 +4405,10 @@ function openEditFilterModal(id) {
   document.getElementById('filter-modal-family').value = String(r.ip_version || 4);
   document.getElementById('filter-modal-enabled').checked = !r.disabled;
   document.getElementById('filter-modal-comment').value = r.comment || '';
-  document.getElementById('filter-modal-chain').value = r.chain || 'forward';
+  const filterChain = r.chain || 'forward';
+  document.getElementById('filter-modal-chain').value = filterChain;
+  populateChainDropdown('filter', filterChain);
+  handleFilterChainChange();
   setFirewallActionSelection('filter', r.action || 'accept');
   document.getElementById('filter-modal-log').checked = !!r.log;
 
@@ -4439,7 +4621,14 @@ async function applyFilterRule(closeAfter = false) {
   const id = document.getElementById('filter-modal-id').value;
   if (!reviewLegacyFirewallSettings('filter', id)) return;
   let selectedDays; try { selectedDays = getFirewallDays('fw'); } catch(error) { showToast('Error', error.message, 'error'); return; }
-  const chain = document.getElementById('filter-modal-chain').value;
+  const chain = (document.getElementById('filter-modal-chain').value || '').trim();
+  const chainLower = chain.toLowerCase();
+  if (!chain) { switchFwRuleTab('general'); showToast('Filter Rule', 'A valid chain is required.', 'error'); document.getElementById('filter-modal-chain').focus(); return; }
+  if (chainLower === 'prerouting' || chainLower === 'postrouting') {
+    switchFwRuleTab('general');
+    showToast('Invalid Chain', `Chain "${chain.toUpperCase()}" is not available in the Filter table in iptables.`, 'error');
+    return;
+  }
   const action = document.getElementById('filter-modal-action').value;
   const comment = document.getElementById('filter-modal-comment').value.trim();
   const enabled = document.getElementById('filter-modal-enabled').checked;
@@ -4458,10 +4647,17 @@ async function applyFilterRule(closeAfter = false) {
     switchFwRuleTab('general'); toggleFwField('proto', true);
     showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('fw-val-proto').focus(); return;
   }
-  const iniface = getFwFieldVal('iniface', true);
-  const outiface = getFwFieldVal('outiface', true);
-  const inifacelist = getFwFieldVal('inifacelist', true);
-  const outifacelist = getFwFieldVal('outifacelist', true);
+  let iniface = getFwFieldVal('iniface', true);
+  let outiface = getFwFieldVal('outiface', true);
+  let inifacelist = getFwFieldVal('inifacelist', true);
+  let outifacelist = getFwFieldVal('outifacelist', true);
+  if (chainLower === 'input') {
+    outiface = '';
+    outifacelist = '';
+  } else if (chainLower === 'output') {
+    iniface = '';
+    inifacelist = '';
+  }
   const pktmark = getFwFieldVal('pktmark', true);
   const connmark = getFwFieldVal('connmark', true);
   const routingmark = getFwFieldVal('routingmark', true);
@@ -4913,10 +5109,130 @@ function toggleMaximizeNATModal() {
   }
 }
 
+function syncNATChainFromSelect() {
+  const sel = document.getElementById('nat-modal-chain-select');
+  const customWrap = document.getElementById('nat-modal-custom-wrap');
+  const customInput = document.getElementById('nat-modal-custom-input');
+  const chainInput = document.getElementById('nat-modal-chain');
+  if (!sel || !chainInput) return;
+
+  if (sel.value === '__custom__') {
+    if (customWrap) customWrap.classList.remove('hidden');
+    if (customInput) {
+      customInput.value = '';
+      customInput.focus();
+    }
+    chainInput.value = '';
+  } else {
+    if (customWrap) customWrap.classList.add('hidden');
+    chainInput.value = sel.value;
+
+    const actEl = document.getElementById('nat-modal-action');
+    if (actEl) {
+      const curAct = actEl.value;
+      if (sel.value === 'prerouting' && ['masquerade', 'src-nat'].includes(curAct)) {
+        actEl.value = 'dst-nat';
+      } else if (sel.value === 'postrouting' && ['dst-nat', 'redirect'].includes(curAct)) {
+        actEl.value = 'masquerade';
+      }
+    }
+  }
+  handleNATChainChange();
+}
+
+function syncNATCustomChainInput() {
+  const customInput = document.getElementById('nat-modal-custom-input');
+  const chainInput = document.getElementById('nat-modal-chain');
+  if (customInput && chainInput) {
+    chainInput.value = customInput.value.trim();
+  }
+  handleNATChainChange();
+}
+
 function handleNATChainChange() {
+  const chainInput = document.getElementById('nat-modal-chain');
+  const rawChain = (chainInput?.value || 'postrouting').trim();
+  const chain = nativeNATChain(rawChain).toLowerCase();
+
+  const sel = document.getElementById('nat-modal-chain-select');
+  if (sel && sel.value !== chain && sel.value !== '__custom__') {
+    if (['postrouting', 'prerouting', 'output', 'input'].includes(chain)) {
+      sel.value = chain;
+    }
+  }
+
+  const hintEl = document.getElementById('nat-chain-hint');
+  if (hintEl) {
+    if (chain === 'prerouting') {
+      hintEl.textContent = 'PREROUTING: destination NAT (DNAT / REDIRECT) before routing decision (In-Interface only).';
+    } else if (chain === 'postrouting') {
+      hintEl.textContent = 'POSTROUTING: source NAT (SNAT / MASQUERADE) after routing decision (Out-Interface only).';
+    } else if (chain === 'output') {
+      hintEl.textContent = 'OUTPUT: destination NAT for locally generated packets (Out-Interface only).';
+    } else if (chain === 'input') {
+      hintEl.textContent = 'INPUT: source NAT for packets destined to local sockets (In-Interface only).';
+    } else {
+      hintEl.textContent = `Custom chain "${rawChain}": user-defined NAT chain reached via JUMP.`;
+    }
+  }
+
+  const rowIn = document.getElementById('nat-row-iniface');
+  const rowInList = document.getElementById('nat-row-inifacelist');
+  const rowOut = document.getElementById('nat-row-outiface');
+  const rowOutList = document.getElementById('nat-row-outifacelist');
+
+  if (chain === 'prerouting' || chain === 'input') {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.add('hidden');
+    if (rowOutList) rowOutList.classList.add('hidden');
+  } else if (chain === 'postrouting' || chain === 'output') {
+    if (rowIn) rowIn.classList.add('hidden');
+    if (rowInList) rowInList.classList.add('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  } else {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  }
+
+  checkNATActionChainCompatibility();
   toggleNATActionFields();
 }
 
+function checkNATActionChainCompatibility() {
+  const rawChain = (document.getElementById('nat-modal-chain')?.value || 'postrouting').trim();
+  const chain = nativeNATChain(rawChain).toLowerCase();
+  const action = (document.getElementById('nat-modal-action')?.value || '').toLowerCase();
+  const warnBox = document.getElementById('nat-action-incompatible-warn');
+  const warnMsg = document.getElementById('nat-action-incompatible-msg');
+  if (!warnBox || !warnMsg) return;
+
+  let msg = '';
+  if (['dst-nat', 'redirect'].includes(action)) {
+    if (chain !== 'prerouting' && chain !== 'output') {
+      msg = `${action === 'dst-nat' ? 'DNAT' : 'REDIRECT'} is only valid in PREROUTING or OUTPUT chains in iptables (current chain: ${chain.toUpperCase()}).`;
+    }
+  } else if (action === 'masquerade') {
+    if (chain !== 'postrouting') {
+      msg = `MASQUERADE is only valid in the POSTROUTING chain in iptables (current chain: ${chain.toUpperCase()}).`;
+    }
+  } else if (action === 'src-nat') {
+    if (chain !== 'postrouting' && chain !== 'input') {
+      msg = `SNAT is only valid in POSTROUTING or INPUT chains in iptables (current chain: ${chain.toUpperCase()}).`;
+    }
+  }
+
+  if (msg) {
+    warnMsg.textContent = msg;
+    warnBox.classList.remove('hidden');
+  } else {
+    warnBox.classList.add('hidden');
+  }
+}
+
 function toggleNATActionFields() {
   const act = document.getElementById('nat-modal-action')?.value || 'masquerade';
   const rowAddr = document.getElementById('nat-row-toaddr');
@@ -4950,6 +5266,7 @@ function toggleNATActionFields() {
 
 function handleNATActionChange() {
   toggleNATActionFields();
+  checkNATActionChainCompatibility();
 }
 
 function toggleNATLogPrefix() {
@@ -4968,6 +5285,8 @@ function openAddNATModal() {
   document.getElementById('nat-modal-enabled').checked = true;
   document.getElementById('nat-modal-comment').value = '';
   document.getElementById('nat-modal-chain').value = 'postrouting';
+  populateChainDropdown('nat', 'postrouting');
+  handleNATChainChange();
   document.getElementById('nat-modal-action').value = 'masquerade';
   document.getElementById('nat-modal-toaddr').value = '';
   document.getElementById('nat-modal-toport').value = '';
@@ -5012,7 +5331,10 @@ function openEditNATModal(id) {
   document.getElementById('nat-modal-family').value = String(r.ip_version || 4);
   document.getElementById('nat-modal-enabled').checked = !r.disabled;
   document.getElementById('nat-modal-comment').value = r.comment || '';
-  document.getElementById('nat-modal-chain').value = nativeNATChain(r.chain || 'postrouting');
+  const natChain = nativeNATChain(r.chain || 'postrouting');
+  document.getElementById('nat-modal-chain').value = natChain;
+  populateChainDropdown('nat', natChain);
+  handleNATChainChange();
   setFirewallActionSelection('nat', r.action || 'accept');
   document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
   document.getElementById('nat-modal-toport').value = r.to_ports || '';
@@ -5153,7 +5475,8 @@ async function applyNATRule(closeAfter = false) {
   const id = document.getElementById('nat-modal-id').value;
   if (!reviewLegacyFirewallSettings('nat', id)) return;
   let selectedDays; try { selectedDays = getFirewallDays('nat'); } catch(error) { showToast('Error', error.message, 'error'); return; }
-  const chain = document.getElementById('nat-modal-chain').value;
+  const chain = (document.getElementById('nat-modal-chain').value || '').trim();
+  const chainLower = nativeNATChain(chain).toLowerCase();
   const action = document.getElementById('nat-modal-action').value;
   const comment = document.getElementById('nat-modal-comment').value.trim();
   const enabled = document.getElementById('nat-modal-enabled').checked;
@@ -5168,10 +5491,19 @@ async function applyNATRule(closeAfter = false) {
   const sport = getNATFieldVal('sport', true);
   const dport = getNATFieldVal('dport', true);
   const anyport = getNATFieldVal('anyport', true);
-  const iniface = getNATFieldVal('iniface', true);
-  const outiface = getNATFieldVal('outiface', true);
-  const inifacelist = getNATFieldVal('inifacelist', true);
-  const outifacelist = getNATFieldVal('outifacelist', true);
+  let iniface = getNATFieldVal('iniface', true);
+  let outiface = getNATFieldVal('outiface', true);
+  let inifacelist = getNATFieldVal('inifacelist', true);
+  let outifacelist = getNATFieldVal('outifacelist', true);
+
+  if (chainLower === 'prerouting' || chainLower === 'input') {
+    outiface = '';
+    outifacelist = '';
+  } else if (chainLower === 'postrouting' || chainLower === 'output') {
+    iniface = '';
+    inifacelist = '';
+  }
+
   const pktmark = getNATFieldVal('pktmark', true);
   const connmark = getNATFieldVal('connmark', true);
   const routingmark = getNATFieldVal('routingmark', true);
@@ -5179,7 +5511,23 @@ async function applyNATRule(closeAfter = false) {
 
   const usesAddr = ['src-nat', 'dst-nat', 'netmap'].includes(action);
   const usesPort = ['src-nat', 'dst-nat', 'redirect', 'masquerade'].includes(action);
-  if (!chain.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter a chain.', 'error'); document.getElementById('nat-modal-chain').focus(); return; }
+  if (!chain) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter a chain.', 'error'); document.getElementById('nat-modal-chain').focus(); return; }
+  if (chainLower === 'forward') { switchNATRuleTab('general'); showToast('Invalid Chain', 'Chain FORWARD is not valid in the NAT table in iptables.', 'error'); return; }
+  if (['dst-nat', 'redirect'].includes(action) && chainLower !== 'prerouting' && chainLower !== 'output') {
+    switchNATRuleTab('general');
+    showToast('Invalid Target for Chain', `${action === 'dst-nat' ? 'DNAT' : 'REDIRECT'} requires PREROUTING or OUTPUT chain in iptables.`, 'error');
+    return;
+  }
+  if (action === 'masquerade' && chainLower !== 'postrouting') {
+    switchNATRuleTab('general');
+    showToast('Invalid Target for Chain', 'MASQUERADE requires POSTROUTING chain in iptables.', 'error');
+    return;
+  }
+  if (action === 'src-nat' && chainLower !== 'postrouting' && chainLower !== 'input') {
+    switchNATRuleTab('general');
+    showToast('Invalid Target for Chain', 'SNAT requires POSTROUTING or INPUT chain in iptables.', 'error');
+    return;
+  }
   if (usesAddr && !document.getElementById('nat-modal-toaddr').value.trim()) { switchNATRuleTab('general'); showToast('NAT rule', 'Enter the destination IP / address for this action.', 'error'); document.getElementById('nat-modal-toaddr').focus(); return; }
   if ((sport || dport || anyport || (usesPort && document.getElementById('nat-modal-toport').value.trim())) && !['tcp', 'udp'].includes(proto)) {
     switchNATRuleTab('general'); toggleNATField('proto', true); document.getElementById('nat-val-proto').value = proto || 'all';
@@ -5561,6 +5909,112 @@ function toggleMaximizeMangleModal() {
   }
 }
 
+function syncMangleChainFromSelect() {
+  const sel = document.getElementById('mangle-modal-chain-select');
+  const customWrap = document.getElementById('mangle-modal-custom-wrap');
+  const customInput = document.getElementById('mangle-modal-custom-input');
+  const chainInput = document.getElementById('mangle-modal-chain');
+  if (!sel || !chainInput) return;
+
+  if (sel.value === '__custom__') {
+    if (customWrap) customWrap.classList.remove('hidden');
+    if (customInput) {
+      customInput.value = '';
+      customInput.focus();
+    }
+    chainInput.value = '';
+  } else {
+    if (customWrap) customWrap.classList.add('hidden');
+    chainInput.value = sel.value;
+  }
+  handleMangleChainChange();
+}
+
+function syncMangleCustomChainInput() {
+  const customInput = document.getElementById('mangle-modal-custom-input');
+  const chainInput = document.getElementById('mangle-modal-chain');
+  if (customInput && chainInput) {
+    chainInput.value = customInput.value.trim();
+  }
+  handleMangleChainChange();
+}
+
+function handleMangleChainChange() {
+  const chainInput = document.getElementById('mangle-modal-chain');
+  const rawChain = (chainInput?.value || 'prerouting').trim();
+  const chain = rawChain.toLowerCase();
+
+  const sel = document.getElementById('mangle-modal-chain-select');
+  if (sel && sel.value !== chain && sel.value !== '__custom__') {
+    if (['prerouting', 'input', 'forward', 'output', 'postrouting'].includes(chain)) {
+      sel.value = chain;
+    }
+  }
+
+  const hintEl = document.getElementById('mangle-chain-hint');
+  if (hintEl) {
+    if (chain === 'prerouting') {
+      hintEl.textContent = 'PREROUTING: packets altered before routing decision (In-Interface only).';
+    } else if (chain === 'input') {
+      hintEl.textContent = 'INPUT: packets altered before local socket delivery (In-Interface only).';
+    } else if (chain === 'forward') {
+      hintEl.textContent = 'FORWARD: transit packets routed through this router (accepts both In and Out interfaces).';
+    } else if (chain === 'output') {
+      hintEl.textContent = 'OUTPUT: locally generated packets before routing decision (Out-Interface only).';
+    } else if (chain === 'postrouting') {
+      hintEl.textContent = 'POSTROUTING: packets altered after routing decision before transmission (Out-Interface only; CLASSIFY supported).';
+    } else {
+      hintEl.textContent = `Custom chain "${rawChain}": user-defined Mangle chain reached via JUMP.`;
+    }
+  }
+
+  const rowIn = document.getElementById('mangle-row-iniface');
+  const rowInList = document.getElementById('mangle-row-inifacelist');
+  const rowOut = document.getElementById('mangle-row-outiface');
+  const rowOutList = document.getElementById('mangle-row-outifacelist');
+
+  if (chain === 'prerouting' || chain === 'input') {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.add('hidden');
+    if (rowOutList) rowOutList.classList.add('hidden');
+  } else if (chain === 'output' || chain === 'postrouting') {
+    if (rowIn) rowIn.classList.add('hidden');
+    if (rowInList) rowInList.classList.add('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  } else {
+    if (rowIn) rowIn.classList.remove('hidden');
+    if (rowInList) rowInList.classList.remove('hidden');
+    if (rowOut) rowOut.classList.remove('hidden');
+    if (rowOutList) rowOutList.classList.remove('hidden');
+  }
+
+  checkMangleActionChainCompatibility();
+  toggleMangleActionFields();
+}
+
+function checkMangleActionChainCompatibility() {
+  const rawChain = (document.getElementById('mangle-modal-chain')?.value || 'prerouting').trim();
+  const chain = rawChain.toLowerCase();
+  const action = (document.getElementById('mangle-modal-action')?.value || '').toLowerCase();
+  const warnBox = document.getElementById('mangle-action-incompatible-warn');
+  const warnMsg = document.getElementById('mangle-action-incompatible-msg');
+  if (!warnBox || !warnMsg) return;
+
+  let msg = '';
+  if (action === 'set-priority' && chain !== 'postrouting') {
+    msg = 'CLASSIFY (set-priority) requires the POSTROUTING chain in iptables.';
+  }
+
+  if (msg) {
+    warnMsg.textContent = msg;
+    warnBox.classList.remove('hidden');
+  } else {
+    warnBox.classList.add('hidden');
+  }
+}
+
 function toggleMangleActionFields() {
   const act = document.getElementById('mangle-modal-action')?.value || 'mark-connection';
   
@@ -5615,6 +6069,7 @@ function toggleMangleActionFields() {
     if (logEl) logEl.checked = true;
     toggleMangleField('logprefix', true);
   }
+  checkMangleActionChainCompatibility();
 }
 
 function toggleMangleLogPrefix() {
@@ -5644,6 +6099,8 @@ function openAddMangleModal() {
   document.getElementById('mangle-modal-enabled').checked = true;
   document.getElementById('mangle-modal-comment').value = '';
   document.getElementById('mangle-modal-chain').value = 'prerouting';
+  populateChainDropdown('mangle', 'prerouting');
+  handleMangleChainChange();
   document.getElementById('mangle-modal-action').value = 'mark-connection';
   
   if (document.getElementById('mangle-modal-newconnmark')) document.getElementById('mangle-modal-newconnmark').value = '';
@@ -5689,7 +6146,10 @@ function openEditMangleModal(id) {
   document.getElementById('mangle-modal-family').value = String(r.ip_version || 4);
   document.getElementById('mangle-modal-enabled').checked = !r.disabled;
   document.getElementById('mangle-modal-comment').value = r.comment || '';
-  document.getElementById('mangle-modal-chain').value = r.chain || 'prerouting';
+  const mangleChain = r.chain || 'prerouting';
+  document.getElementById('mangle-modal-chain').value = mangleChain;
+  populateChainDropdown('mangle', mangleChain);
+  handleMangleChainChange();
   setFirewallActionSelection('mangle', r.action || 'accept');
   
   if (document.getElementById('mangle-modal-newconnmark')) document.getElementById('mangle-modal-newconnmark').value = r.new_connection_mark || '';
@@ -5864,7 +6324,8 @@ function resetMangleCounterDisplay() {
 async function applyMangleRule(closeAfter = false) {
   const id = document.getElementById('mangle-modal-id').value;
   if (!reviewLegacyFirewallSettings('mangle', id)) return;
-  const chain = document.getElementById('mangle-modal-chain').value;
+  const chain = (document.getElementById('mangle-modal-chain').value || '').trim();
+  const chainLower = chain.toLowerCase();
   const action = document.getElementById('mangle-modal-action').value;
   const comment = document.getElementById('mangle-modal-comment').value.trim();
   const enabled = document.getElementById('mangle-modal-enabled').checked;
@@ -5883,10 +6344,25 @@ async function applyMangleRule(closeAfter = false) {
     switchMangleRuleTab('general'); toggleMangleField('proto', true);
     showToast('Select protocol', 'Port matches require TCP or UDP without protocol inversion.', 'error'); document.getElementById('mangle-val-proto').focus(); return;
   }
-  const iniface = getMangleFieldVal('iniface', true);
-  const outiface = getMangleFieldVal('outiface', true);
-  const inifacelist = getMangleFieldVal('inifacelist', true);
-  const outifacelist = getMangleFieldVal('outifacelist', true);
+  let iniface = getMangleFieldVal('iniface', true);
+  let outiface = getMangleFieldVal('outiface', true);
+  let inifacelist = getMangleFieldVal('inifacelist', true);
+  let outifacelist = getMangleFieldVal('outifacelist', true);
+
+  if (chainLower === 'prerouting' || chainLower === 'input') {
+    outiface = '';
+    outifacelist = '';
+  } else if (chainLower === 'output' || chainLower === 'postrouting') {
+    iniface = '';
+    inifacelist = '';
+  }
+
+  if (!chain) { switchMangleRuleTab('general'); showToast('Mangle rule', 'Enter a chain.', 'error'); document.getElementById('mangle-modal-chain').focus(); return; }
+  if (action === 'set-priority' && chainLower !== 'postrouting') {
+    switchMangleRuleTab('general');
+    showToast('Invalid Target for Chain', 'CLASSIFY (set-priority) requires POSTROUTING chain in iptables.', 'error');
+    return;
+  }
   const pktmark = getMangleFieldVal('pktmark', true);
   const connmark = getMangleFieldVal('connmark', true);
   const routingmark = getMangleFieldVal('routingmark', true);

+ 56 - 8
internal/web/static/index.html

@@ -5836,12 +5836,27 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <input id="filter-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="filter-chain-options" maxlength="16" required><datalist id="filter-chain-options">
-                  <option value="forward" selected>forward</option>
+                <select id="filter-modal-chain-select" onchange="syncFilterChainFromSelect()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                  <option value="forward" selected>FORWARD (routed through router)</option>
+                  <option value="input">INPUT (destined to this router)</option>
+                  <option value="output">OUTPUT (generated by this router)</option>
+                  <option value="__custom__">+ Custom Chain...</option>
+                </select>
+                <div id="filter-modal-custom-wrap" class="hidden mt-1.5 flex items-center gap-1.5">
+                  <span class="text-[10px] text-amber-400 font-mono uppercase tracking-wider shrink-0">Custom Chain:</span>
+                  <input type="text" id="filter-modal-custom-input" oninput="syncFilterCustomChainInput()" placeholder="Enter custom chain name (1-16 chars)" maxlength="16" class="bg-[#15202e] border border-amber-500/50 rounded px-2.5 py-1 text-xs text-amber-300 font-mono focus:outline-none focus:border-amber-400 w-full">
+                </div>
+                <input type="hidden" id="filter-modal-chain" value="forward">
+                <datalist id="filter-chain-options">
+                  <option value="forward">forward</option>
                   <option value="input">input</option>
                   <option value="output">output</option>
                 </datalist>
-                <p class="mt-1 text-slate-400">input: to this machine; forward: through it; output: from this machine. Custom chains require a jump.</p>
+                <p id="filter-chain-hint" class="mt-1 text-[11px] text-slate-400">FORWARD: packets routed through this router (accepts both In and Out interfaces).</p>
+                <div id="filter-action-incompatible-warn" class="hidden mt-1.5 p-2 bg-amber-950/40 border border-amber-600/40 rounded text-amber-300 text-[11px] flex items-center gap-2">
+                  <svg class="w-4 h-4 text-amber-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+                  <span id="filter-action-incompatible-msg" class="flex-1 font-mono"></span>
+                </div>
               </div>
             </div>
 
@@ -6692,13 +6707,29 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <input id="nat-modal-chain" oninput="handleNATChainChange()" onchange="handleNATChainChange()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="nat-chain-options" maxlength="16" required><datalist id="nat-chain-options">
+                <select id="nat-modal-chain-select" onchange="syncNATChainFromSelect()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                  <option value="postrouting" selected>POSTROUTING (outgoing after routing — SNAT / MASQUERADE)</option>
+                  <option value="prerouting">PREROUTING (incoming before routing — DNAT / REDIRECT)</option>
+                  <option value="output">OUTPUT (locally generated — DNAT / REDIRECT)</option>
+                  <option value="input">INPUT (destined to this machine — SNAT)</option>
+                  <option value="__custom__">+ Custom Chain...</option>
+                </select>
+                <div id="nat-modal-custom-wrap" class="hidden mt-1.5 flex items-center gap-1.5">
+                  <span class="text-[10px] text-amber-400 font-mono uppercase tracking-wider shrink-0">Custom Chain:</span>
+                  <input type="text" id="nat-modal-custom-input" oninput="syncNATCustomChainInput()" placeholder="Enter custom chain name (1-16 chars)" maxlength="16" class="bg-[#15202e] border border-amber-500/50 rounded px-2.5 py-1 text-xs text-amber-300 font-mono focus:outline-none focus:border-amber-400 w-full">
+                </div>
+                <input type="hidden" id="nat-modal-chain" value="postrouting" oninput="handleNATChainChange()" onchange="handleNATChainChange()">
+                <datalist id="nat-chain-options">
                   <option value="prerouting">PREROUTING — incoming before routing</option>
                   <option value="input">INPUT — delivered to this machine</option>
                   <option value="output">OUTPUT — generated by this machine</option>
                   <option value="postrouting">POSTROUTING — outgoing after routing</option>
                 </datalist>
-                <p class="mt-1 text-slate-400">PREROUTING: incoming DNAT; POSTROUTING: SNAT/MASQUERADE. Custom chains require a jump from an active chain.</p>
+                <p id="nat-chain-hint" class="mt-1 text-[11px] text-slate-400">POSTROUTING: SNAT/MASQUERADE on traffic leaving this router (Out-Interface only).</p>
+                <div id="nat-action-incompatible-warn" class="hidden mt-1.5 p-2 bg-amber-950/40 border border-amber-600/40 rounded text-amber-300 text-[11px] flex items-center gap-2">
+                  <svg class="w-4 h-4 text-amber-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+                  <span id="nat-action-incompatible-msg" class="flex-1 font-mono"></span>
+                </div>
               </div>
             </div>
 
@@ -7507,14 +7538,31 @@
             <div class="flex items-center text-xs py-1">
               <label class="w-36 sm:w-44 text-right pr-3.5 text-slate-300 shrink-0 select-none">Chain</label>
               <div class="flex-1">
-                <input id="mangle-modal-chain" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer" list="mangle-chain-options" maxlength="16" required><datalist id="mangle-chain-options">
-                  <option value="prerouting" selected>prerouting</option>
+                <select id="mangle-modal-chain-select" onchange="syncMangleChainFromSelect()" class="bg-[#15202e] border border-[#26374d] rounded px-2.5 py-1 text-xs text-sky-400 font-mono focus:outline-none focus:border-sky-500 w-full cursor-pointer">
+                  <option value="prerouting" selected>PREROUTING (incoming before routing)</option>
+                  <option value="input">INPUT (destined to this machine)</option>
+                  <option value="forward">FORWARD (routed through router)</option>
+                  <option value="output">OUTPUT (generated by this machine)</option>
+                  <option value="postrouting">POSTROUTING (outgoing after routing)</option>
+                  <option value="__custom__">+ Custom Chain...</option>
+                </select>
+                <div id="mangle-modal-custom-wrap" class="hidden mt-1.5 flex items-center gap-1.5">
+                  <span class="text-[10px] text-amber-400 font-mono uppercase tracking-wider shrink-0">Custom Chain:</span>
+                  <input type="text" id="mangle-modal-custom-input" oninput="syncMangleCustomChainInput()" placeholder="Enter custom chain name (1-16 chars)" maxlength="16" class="bg-[#15202e] border border-amber-500/50 rounded px-2.5 py-1 text-xs text-amber-300 font-mono focus:outline-none focus:border-amber-400 w-full">
+                </div>
+                <input type="hidden" id="mangle-modal-chain" value="prerouting" oninput="handleMangleChainChange()" onchange="handleMangleChainChange()">
+                <datalist id="mangle-chain-options">
+                  <option value="prerouting">prerouting</option>
                   <option value="postrouting">postrouting</option>
                   <option value="forward">forward</option>
                   <option value="input">input</option>
                   <option value="output">output</option>
                 </datalist>
-                <p class="mt-1 text-slate-400">Choose where packets are processed. Custom chains require a jump from an active chain.</p>
+                <p id="mangle-chain-hint" class="mt-1 text-[11px] text-slate-400">PREROUTING: packets altered before routing decision (In-Interface only).</p>
+                <div id="mangle-action-incompatible-warn" class="hidden mt-1.5 p-2 bg-amber-950/40 border border-amber-600/40 rounded text-amber-300 text-[11px] flex items-center gap-2">
+                  <svg class="w-4 h-4 text-amber-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+                  <span id="mangle-action-incompatible-msg" class="flex-1 font-mono"></span>
+                </div>
               </div>
             </div>