Przeglądaj źródła

Replicate RouterOS WebFig WinBox v4 dialog for Firewall NAT Rules with 5 tabs, interactive matchers, action parameters, right action sidebar, and live statistics graphs

Proxmox Router 1 tydzień temu
rodzic
commit
1333e7cb72

+ 3 - 0
internal/api/firewall_handlers.go

@@ -92,6 +92,9 @@ func (s *Server) handleSaveNATRule(w http.ResponseWriter, r *http.Request) {
 		writeError(w, http.StatusBadRequest, "invalid request body")
 		return
 	}
+	if rule.ID == "" {
+		rule.ID = fmt.Sprintf("nat-%d", time.Now().UnixNano())
+	}
 	if err := s.fwMgr.SaveNATRule(rule); err != nil {
 		writeError(w, http.StatusInternalServerError, err.Error())
 		return

+ 46 - 0
internal/firewall/types.go

@@ -99,6 +99,52 @@ type NATRule struct {
 	Disabled       bool   `json:"disabled"`
 	Packets        uint64 `json:"packets"`
 	Bytes          uint64 `json:"bytes"`
+
+	// Extended WebFig / WinBox v4 properties
+	AnyPort          string `json:"any_port,omitempty"`
+	InInterfaceList  string `json:"in_interface_list,omitempty"`
+	OutInterfaceList string `json:"out_interface_list,omitempty"`
+	PacketMark       string `json:"packet_mark,omitempty"`
+	ConnectionMark   string `json:"connection_mark,omitempty"`
+	RoutingMark      string `json:"routing_mark,omitempty"`
+	ConnectionType   string `json:"connection_type,omitempty"`
+
+	// Advanced tab
+	Layer7Protocol    string `json:"layer7_protocol,omitempty"`
+	Content           string `json:"content,omitempty"`
+	ConnectionBytes   string `json:"connection_bytes,omitempty"`
+	ConnectionRate    string `json:"connection_rate,omitempty"`
+	PCC               string `json:"pcc,omitempty"`
+	SrcMACAddress     string `json:"src_mac_address,omitempty"`
+	OutBridgePort     string `json:"out_bridge_port,omitempty"`
+	InBridgePort      string `json:"in_bridge_port,omitempty"`
+	InBridgePortList  string `json:"in_bridge_port_list,omitempty"`
+	OutBridgePortList string `json:"out_bridge_port_list,omitempty"`
+	IPsecPolicy       string `json:"ipsec_policy,omitempty"`
+	IngressPriority   string `json:"ingress_priority,omitempty"`
+	Priority          string `json:"priority,omitempty"`
+	DSCP              string `json:"dscp,omitempty"`
+	TCPMSS            string `json:"tcp_mss,omitempty"`
+	TOS               string `json:"tos,omitempty"`
+	PacketSize        string `json:"packet_size,omitempty"`
+	Random            string `json:"random,omitempty"`
+	ICMPOptions       string `json:"icmp_options,omitempty"`
+	IPv4Options       string `json:"ipv4_options,omitempty"`
+	TTL               string `json:"ttl,omitempty"`
+
+	// Extra tab
+	Time     string `json:"time,omitempty"`
+	Days     string `json:"days,omitempty"`
+	Limit    string `json:"limit,omitempty"`
+	DstLimit string `json:"dst_limit,omitempty"`
+	PSD      string `json:"psd,omitempty"`
+
+	// Action tab extended
+	Log                bool   `json:"log"`
+	LogPrefix          string `json:"log_prefix,omitempty"`
+	JumpTarget         string `json:"jump_target,omitempty"`
+	AddressList        string `json:"address_list,omitempty"`
+	AddressListTimeout string `json:"address_list_timeout,omitempty"`
 }
 
 // MangleRule represents a MikroTik-style /ip firewall mangle rule

+ 452 - 114
internal/web/static/app.js

@@ -3511,35 +3511,172 @@ function renderNATRules() {
   });
 }
 
+// ==================== FIREWALL NAT MODAL (ROUTEROS WEBFIG / WINBOX V4) ====================
+
+function switchNATRuleTab(tab) {
+  state.activeNATRuleTab = tab;
+  document.querySelectorAll('.nat-rule-tab-btn').forEach(btn => {
+    btn.classList.remove('bg-[#182638]', 'text-white', 'border-sky-400');
+    btn.classList.add('text-slate-400', 'border-transparent');
+  });
+  const activeBtn = document.getElementById(`nat-tab-btn-${tab}`);
+  if (activeBtn) {
+    activeBtn.classList.remove('text-slate-400', 'border-transparent');
+    activeBtn.classList.add('bg-[#182638]', 'text-white', 'border-sky-400');
+  }
+  document.querySelectorAll('.nat-rule-pane').forEach(p => p.classList.add('hidden'));
+  const activePane = document.getElementById(`nat-pane-${tab}`);
+  if (activePane) activePane.classList.remove('hidden');
+
+  if (tab === 'statistics') {
+    startNATStatPolling();
+  } else {
+    stopNATStatPolling();
+  }
+}
+
+function toggleNATField(fieldKey, expand) {
+  const plusBtn = document.getElementById(`nat-plus-${fieldKey}`);
+  const fieldBox = document.getElementById(`nat-field-${fieldKey}`);
+  if (!plusBtn || !fieldBox) return;
+
+  if (expand) {
+    plusBtn.classList.add('hidden');
+    fieldBox.classList.remove('hidden');
+    const valInput = document.getElementById(`nat-val-${fieldKey}`);
+    if (valInput) valInput.focus();
+  } else {
+    fieldBox.classList.add('hidden');
+    plusBtn.classList.remove('hidden');
+    const valInput = document.getElementById(`nat-val-${fieldKey}`);
+    if (valInput) {
+      if (valInput.tagName === 'SELECT') {
+        valInput.selectedIndex = 0;
+      } else {
+        valInput.value = '';
+      }
+    }
+    const invInput = document.getElementById(`nat-inv-${fieldKey}`);
+    if (invInput) invInput.checked = false;
+
+    if (fieldKey === 'days') {
+      ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
+        const el = document.getElementById(`nat-day-${d}`);
+        if (el) el.checked = true;
+      });
+    }
+  }
+}
+
+function setNATFieldVal(fieldKey, value, hasInvert = false) {
+  if (value !== undefined && value !== null && value !== '' && value !== 'all') {
+    toggleNATField(fieldKey, true);
+    let strVal = String(value);
+    if (hasInvert) {
+      const inv = strVal.startsWith('!');
+      if (inv) strVal = strVal.slice(1);
+      const invEl = document.getElementById(`nat-inv-${fieldKey}`);
+      if (invEl) invEl.checked = inv;
+    }
+    const valEl = document.getElementById(`nat-val-${fieldKey}`);
+    if (valEl) valEl.value = strVal;
+  } else {
+    toggleNATField(fieldKey, false);
+  }
+}
+
+function getNATFieldVal(fieldKey, hasInvert = false) {
+  const fieldBox = document.getElementById(`nat-field-${fieldKey}`);
+  if (!fieldBox || fieldBox.classList.contains('hidden')) return '';
+  const valEl = document.getElementById(`nat-val-${fieldKey}`);
+  let v = valEl ? valEl.value.trim() : '';
+  if (!v) return '';
+  if (hasInvert) {
+    const invEl = document.getElementById(`nat-inv-${fieldKey}`);
+    if (invEl && invEl.checked) {
+      v = '!' + v.replace(/^!/, '');
+    }
+  }
+  return v;
+}
+
+function toggleMaximizeNATModal() {
+  const win = document.getElementById('nat-modal-window');
+  if (!win) return;
+  if (win.classList.contains('max-w-[720px]')) {
+    win.classList.remove('max-w-[720px]', 'max-h-[92vh]');
+    win.classList.add('max-w-[96vw]', 'h-[94vh]');
+  } else {
+    win.classList.remove('max-w-[96vw]', 'h-[94vh]');
+    win.classList.add('max-w-[720px]', 'max-h-[92vh]');
+  }
+}
+
+function handleNATChainChange() {
+  const ch = document.getElementById('nat-modal-chain')?.value || 'srcnat';
+  const actSel = document.getElementById('nat-modal-action');
+  if (actSel) {
+    if (ch === 'srcnat' && (actSel.value === 'dst-nat' || actSel.value === 'redirect')) {
+      actSel.value = 'masquerade';
+    } else if (ch === 'dstnat' && (actSel.value === 'masquerade' || actSel.value === 'src-nat')) {
+      actSel.value = 'dst-nat';
+    }
+  }
+  toggleNATActionFields();
+}
+
+function toggleNATActionFields() {
+  const act = document.getElementById('nat-modal-action')?.value || 'masquerade';
+  const toBox = document.getElementById('nat-action-to-box');
+  const jumpBox = document.getElementById('nat-action-jump-box');
+  const addrBox = document.getElementById('nat-action-addrlist-box');
+
+  if (toBox) {
+    const needsTo = ['src-nat', 'dst-nat', 'redirect', 'netmap', 'same', 'endpoint-independent-nat'].includes(act);
+    if (needsTo) toBox.classList.remove('hidden');
+    else toBox.classList.add('hidden');
+  }
+  if (jumpBox) {
+    if (act === 'jump') jumpBox.classList.remove('hidden');
+    else jumpBox.classList.add('hidden');
+  }
+  if (addrBox) {
+    if (act.includes('address-list') || act.includes('address list')) addrBox.classList.remove('hidden');
+    else addrBox.classList.add('hidden');
+  }
+}
+
 function openAddNATModal() {
-  document.getElementById('nat-modal-title').innerText = 'Add NAT Rule';
+  switchNATRuleTab('general');
+  const badge = document.getElementById('nat-modal-badge');
+  if (badge) badge.innerText = 'New...';
   document.getElementById('nat-modal-id').value = '';
+  document.getElementById('nat-modal-enabled').checked = true;
+  document.getElementById('nat-modal-comment').value = '';
   document.getElementById('nat-modal-chain').value = 'srcnat';
   document.getElementById('nat-modal-action').value = 'masquerade';
   document.getElementById('nat-modal-toaddr').value = '';
   document.getElementById('nat-modal-toport').value = '';
-  document.getElementById('nat-modal-proto').value = 'all';
-  document.getElementById('nat-modal-src').value = '';
-  document.getElementById('nat-modal-dst').value = '';
-  document.getElementById('nat-modal-dport').value = '';
-  document.getElementById('nat-modal-comment').value = '';
-  document.getElementById('nat-modal-disabled').checked = false;
+  document.getElementById('nat-modal-log').checked = false;
 
-  populateInterfaceSelect('nat-modal-iniface');
-  populateInterfaceSelect('nat-modal-outiface');
-  populateAddressListSelect('nat-modal-srclist');
-  populateAddressListSelect('nat-modal-dstlist');
-  const srcInvEl = document.getElementById('nat-modal-srclist-inv');
-  if (srcInvEl) srcInvEl.checked = false;
-  const dstInvEl = document.getElementById('nat-modal-dstlist-inv');
-  if (dstInvEl) dstInvEl.checked = false;
+  populateInterfaceSelect('nat-val-iniface');
+  populateInterfaceSelect('nat-val-outiface');
+  populateAddressListSelect('nat-val-srclist');
+  populateAddressListSelect('nat-val-dstlist');
 
-  populateGeoIPSelect('nat-modal-srcgeoip');
-  populateGeoIPSelect('nat-modal-dstgeoip');
-  const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv');
-  if (srcGeoInvEl) srcGeoInvEl.checked = false;
-  const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv');
-  if (dstGeoInvEl) dstGeoInvEl.checked = false;
+  const allFieldKeys = [
+    'src', 'dst', 'srclist', 'dstlist', 'proto', 'sport', 'dport', 'anyport',
+    'iniface', 'outiface', 'inifacelist', 'outifacelist', 'pktmark', 'connmark',
+    'routingmark', 'conntype',
+    'layer7', 'content', 'connbytes', 'connrate', 'pcc', 'srcmac',
+    'outbridgeport', 'inbridgeport', 'inbridgeportlist', 'outbridgeportlist',
+    'ipsec', 'ingresspriority', 'priority', 'dscp', 'tcpmss',
+    'pktsize', 'random', 'icmpoptions', 'ipv4options', 'ttl',
+    'time', 'days', 'limit', 'dstlimit', 'psd', 'logprefix'
+  ];
+  allFieldKeys.forEach(k => toggleNATField(k, false));
+
+  resetCurrentNATRuleCounters();
   toggleNATActionFields();
 
   document.getElementById('nat-modal').classList.remove('hidden');
@@ -3549,137 +3686,338 @@ function openEditNATModal(id) {
   const r = (state.natRules || []).find(rule => rule.id === id);
   if (!r) return;
 
-  document.getElementById('nat-modal-title').innerText = `Edit NAT Rule (#${r.order})`;
+  switchNATRuleTab('general');
+  const badge = document.getElementById('nat-modal-badge');
+  if (badge) badge.innerText = `#${r.order != null ? r.order : r.id}`;
+
   document.getElementById('nat-modal-id').value = r.id;
+  document.getElementById('nat-modal-enabled').checked = !r.disabled;
+  document.getElementById('nat-modal-comment').value = r.comment || '';
   document.getElementById('nat-modal-chain').value = r.chain || 'srcnat';
   document.getElementById('nat-modal-action').value = r.action || 'masquerade';
   document.getElementById('nat-modal-toaddr').value = r.to_addresses || '';
   document.getElementById('nat-modal-toport').value = r.to_ports || '';
-  document.getElementById('nat-modal-proto').value = r.protocol || 'all';
-  document.getElementById('nat-modal-src').value = r.src_address || '';
-  document.getElementById('nat-modal-dst').value = r.dst_address || '';
-  document.getElementById('nat-modal-dport').value = r.dst_port || '';
-  document.getElementById('nat-modal-comment').value = r.comment || '';
-  document.getElementById('nat-modal-disabled').checked = !!r.disabled;
+  document.getElementById('nat-modal-log').checked = !!r.log;
 
-  populateInterfaceSelect('nat-modal-iniface', r.in_interface);
-  populateInterfaceSelect('nat-modal-outiface', r.out_interface);
+  populateInterfaceSelect('nat-val-iniface', (r.in_interface || '').replace(/^!/, ''));
+  populateInterfaceSelect('nat-val-outiface', (r.out_interface || '').replace(/^!/, ''));
+  populateAddressListSelect('nat-val-srclist', (r.src_address_list || '').replace(/^!/, ''));
+  populateAddressListSelect('nat-val-dstlist', (r.dst_address_list || '').replace(/^!/, ''));
 
-  const srcInv = (r.src_address_list || '').startsWith('!');
-  const dstInv = (r.dst_address_list || '').startsWith('!');
-  const srcInvEl = document.getElementById('nat-modal-srclist-inv');
-  if (srcInvEl) srcInvEl.checked = srcInv;
-  const dstInvEl = document.getElementById('nat-modal-dstlist-inv');
-  if (dstInvEl) dstInvEl.checked = dstInv;
-  populateAddressListSelect('nat-modal-srclist', r.src_address_list || '');
-  populateAddressListSelect('nat-modal-dstlist', r.dst_address_list || '');
+  setNATFieldVal('src', r.src_address, true);
+  setNATFieldVal('dst', r.dst_address, true);
+  setNATFieldVal('srclist', r.src_address_list, true);
+  setNATFieldVal('dstlist', r.dst_address_list, true);
+  if (r.protocol && r.protocol !== 'all') {
+    setNATFieldVal('proto', r.protocol, true);
+  } else {
+    toggleNATField('proto', false);
+  }
+  setNATFieldVal('sport', r.src_port, true);
+  setNATFieldVal('dport', r.dst_port, true);
+  setNATFieldVal('anyport', r.any_port, true);
+  setNATFieldVal('iniface', r.in_interface, true);
+  setNATFieldVal('outiface', r.out_interface, true);
+  setNATFieldVal('inifacelist', r.in_interface_list, true);
+  setNATFieldVal('outifacelist', r.out_interface_list, true);
+  setNATFieldVal('pktmark', r.packet_mark, true);
+  setNATFieldVal('connmark', r.connection_mark, true);
+  setNATFieldVal('routingmark', r.routing_mark, true);
+  setNATFieldVal('conntype', r.connection_type, false);
 
-  const srcGeoInv = (r.src_geoip || '').startsWith('!');
-  const dstGeoInv = (r.dst_geoip || '').startsWith('!');
-  const srcGeoInvEl = document.getElementById('nat-modal-srcgeoip-inv');
-  if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv;
-  const dstGeoInvEl = document.getElementById('nat-modal-dstgeoip-inv');
-  if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv;
-  populateGeoIPSelect('nat-modal-srcgeoip', r.src_geoip || '');
-  populateGeoIPSelect('nat-modal-dstgeoip', r.dst_geoip || '');
-  toggleNATActionFields();
+  // Advanced tab fields
+  setNATFieldVal('layer7', r.layer7_protocol, false);
+  setNATFieldVal('content', r.content, false);
+  setNATFieldVal('connbytes', r.connection_bytes, false);
+  setNATFieldVal('connrate', r.connection_rate, false);
+  setNATFieldVal('pcc', r.pcc, false);
+  setNATFieldVal('srcmac', r.src_mac_address, true);
+  setNATFieldVal('outbridgeport', r.out_bridge_port, false);
+  setNATFieldVal('inbridgeport', r.in_bridge_port, false);
+  setNATFieldVal('inbridgeportlist', r.in_bridge_port_list, false);
+  setNATFieldVal('outbridgeportlist', r.out_bridge_port_list, false);
+  setNATFieldVal('ipsec', r.ipsec_policy, false);
+  setNATFieldVal('ingresspriority', r.ingress_priority, false);
+  setNATFieldVal('priority', r.priority, false);
+  setNATFieldVal('dscp', r.dscp, false);
+  setNATFieldVal('tcpmss', r.tcp_mss, false);
+  if (r.tos) {
+    const tosEl = document.getElementById('nat-val-tos');
+    if (tosEl) tosEl.value = r.tos;
+  }
+  setNATFieldVal('pktsize', r.packet_size, false);
+  setNATFieldVal('random', r.random, false);
+  setNATFieldVal('icmpoptions', r.icmp_options, false);
+  setNATFieldVal('ipv4options', r.ipv4_options, false);
+  setNATFieldVal('ttl', r.ttl, false);
 
+  // Extra tab fields
+  setNATFieldVal('time', r.time, false);
+  if (r.days) {
+    toggleNATField('days', true);
+    ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].forEach(d => {
+      const chk = document.getElementById(`nat-day-${d}`);
+      if (chk) chk.checked = r.days.includes(d);
+    });
+  } else {
+    toggleNATField('days', false);
+  }
+  setNATFieldVal('limit', r.limit, false);
+  setNATFieldVal('dstlimit', r.dst_limit, false);
+  setNATFieldVal('psd', r.psd, false);
+
+  // Action fields
+  if (r.jump_target) {
+    const jtEl = document.getElementById('nat-modal-jumptarget');
+    if (jtEl) jtEl.value = r.jump_target;
+  }
+  if (r.address_list) {
+    const alEl = document.getElementById('nat-modal-action-addrlist');
+    if (alEl) alEl.value = r.address_list;
+  }
+  if (r.address_list_timeout) {
+    const toEl = document.getElementById('nat-modal-action-timeout');
+    if (toEl) toEl.value = r.address_list_timeout;
+  }
+  setNATFieldVal('logprefix', r.log_prefix, false);
+
+  // Statistics
+  const bEl = document.getElementById('nat-stat-bytes');
+  const pEl = document.getElementById('nat-stat-packets');
+  if (bEl) bEl.value = formatTraffic(r.bytes || 0).replace('/s', '');
+  if (pEl) pEl.value = (r.packets || 0).toLocaleString();
+  state.natLastBytes = r.bytes || 0;
+  state.natLastPackets = r.packets || 0;
+  state.natLastTimestamp = Date.now();
+  state.natStatRateHistory = new Array(30).fill(0);
+  state.natStatPktHistory = new Array(30).fill(0);
+  renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate');
+  renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate');
+
+  toggleNATActionFields();
   document.getElementById('nat-modal').classList.remove('hidden');
 }
 
 function closeNATModal() {
+  stopNATStatPolling();
   document.getElementById('nat-modal').classList.add('hidden');
 }
 
-function toggleNATChainFields() {
-  const ch = document.getElementById('nat-modal-chain').value;
-  const actSel = document.getElementById('nat-modal-action');
-  if (ch === 'srcnat' && actSel.value === 'dst-nat') {
-    actSel.value = 'masquerade';
-  } else if (ch === 'dstnat' && actSel.value === 'masquerade') {
-    actSel.value = 'dst-nat';
-  }
-  toggleNATActionFields();
-}
-
-function toggleNATActionFields() {
-  const act = document.getElementById('nat-modal-action').value;
-  const toBox = document.getElementById('nat-action-to-box');
-  if (toBox) {
-    if (act === 'masquerade' || act === 'accept') toBox.classList.add('hidden');
-    else toBox.classList.remove('hidden');
-  }
+function copyNATRule() {
+  document.getElementById('nat-modal-id').value = '';
+  const badge = document.getElementById('nat-modal-badge');
+  if (badge) badge.innerText = 'New...';
+  showToast('Rule Cloned', 'Ready to save as a new NAT rule copy', 'info');
+}
+
+function resetCurrentNATRuleCounters() {
+  state.natStatRateHistory = new Array(30).fill(0);
+  state.natStatPktHistory = new Array(30).fill(0);
+  state.natLastBytes = 0;
+  state.natLastPackets = 0;
+  state.natLastTimestamp = Date.now();
+  const bEl = document.getElementById('nat-stat-bytes');
+  const pEl = document.getElementById('nat-stat-packets');
+  const rEl = document.getElementById('nat-stat-rate');
+  const prEl = document.getElementById('nat-stat-pktrate');
+  if (bEl) bEl.value = '0 B';
+  if (pEl) pEl.value = '0';
+  if (rEl) rEl.value = '0 bps';
+  if (prEl) prEl.value = '0 p/s';
+  renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate');
+  renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate');
+  showToast('Counters Reset', 'NAT rule counter display reset to 0', 'info');
 }
 
-async function submitSaveNATRule(e) {
-  e.preventDefault();
+async function applyNATRule(closeAfter = false) {
   const id = document.getElementById('nat-modal-id').value;
   const chain = document.getElementById('nat-modal-chain').value;
   const action = document.getElementById('nat-modal-action').value;
-  const toaddr = document.getElementById('nat-modal-toaddr').value.trim();
-  const toport = document.getElementById('nat-modal-toport').value.trim();
-  const proto = document.getElementById('nat-modal-proto').value;
-  const src = document.getElementById('nat-modal-src').value.trim();
-  const dst = document.getElementById('nat-modal-dst').value.trim();
-  const dport = document.getElementById('nat-modal-dport').value.trim();
-  const inIface = document.getElementById('nat-modal-iniface').value;
-  const outIface = document.getElementById('nat-modal-outiface').value;
-
-  let srclist = document.getElementById('nat-modal-srclist')?.value || '';
-  if (srclist && document.getElementById('nat-modal-srclist-inv')?.checked) {
-    srclist = '!' + srclist.replace(/^!/, '');
-  }
-  let dstlist = document.getElementById('nat-modal-dstlist')?.value || '';
-  if (dstlist && document.getElementById('nat-modal-dstlist-inv')?.checked) {
-    dstlist = '!' + dstlist.replace(/^!/, '');
-  }
+  const comment = document.getElementById('nat-modal-comment').value.trim();
+  const enabled = document.getElementById('nat-modal-enabled').checked;
+
+  const src = getNATFieldVal('src', true);
+  const dst = getNATFieldVal('dst', true);
+  const srclist = getNATFieldVal('srclist', true);
+  const dstlist = getNATFieldVal('dstlist', true);
+  const proto = getNATFieldVal('proto', true);
+  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);
+  const pktmark = getNATFieldVal('pktmark', true);
+  const connmark = getNATFieldVal('connmark', true);
+  const routingmark = getNATFieldVal('routingmark', true);
+  const conntype = getNATFieldVal('conntype', false);
 
-  let srcgeoip = document.getElementById('nat-modal-srcgeoip')?.value || '';
-  if (srcgeoip && document.getElementById('nat-modal-srcgeoip-inv')?.checked) {
-    srcgeoip = '!' + srcgeoip.replace(/^!/, '');
-  }
-  let dstgeoip = document.getElementById('nat-modal-dstgeoip')?.value || '';
-  if (dstgeoip && document.getElementById('nat-modal-dstgeoip-inv')?.checked) {
-    dstgeoip = '!' + dstgeoip.replace(/^!/, '');
-  }
+  const payload = {
+    id,
+    chain,
+    action,
+    to_addresses: document.getElementById('nat-modal-toaddr')?.value.trim() || '',
+    to_ports: document.getElementById('nat-modal-toport')?.value.trim() || '',
+    protocol: proto || 'all',
+    src_address: src,
+    dst_address: dst,
+    src_port: sport,
+    dst_port: dport,
+    any_port: anyport,
+    in_interface: iniface,
+    out_interface: outiface,
+    in_interface_list: inifacelist,
+    out_interface_list: outifacelist,
+    packet_mark: pktmark,
+    connection_mark: connmark,
+    routing_mark: routingmark,
+    connection_type: conntype,
+    src_address_list: srclist,
+    dst_address_list: dstlist,
+    comment,
+    disabled: !enabled,
 
-  const comment = document.getElementById('nat-modal-comment').value.trim();
-  const disabled = document.getElementById('nat-modal-disabled').checked;
+    // Advanced tab
+    layer7_protocol: getNATFieldVal('layer7', false),
+    content: getNATFieldVal('content', false),
+    connection_bytes: getNATFieldVal('connbytes', false),
+    connection_rate: getNATFieldVal('connrate', false),
+    pcc: getNATFieldVal('pcc', false),
+    src_mac_address: getNATFieldVal('srcmac', true),
+    out_bridge_port: getNATFieldVal('outbridgeport', false),
+    in_bridge_port: getNATFieldVal('inbridgeport', false),
+    in_bridge_port_list: getNATFieldVal('inbridgeportlist', false),
+    out_bridge_port_list: getNATFieldVal('outbridgeportlist', false),
+    ipsec_policy: getNATFieldVal('ipsec', false),
+    ingress_priority: getNATFieldVal('ingresspriority', false),
+    priority: getNATFieldVal('priority', false),
+    dscp: getNATFieldVal('dscp', false),
+    tcp_mss: getNATFieldVal('tcpmss', false),
+    tos: document.getElementById('nat-val-tos')?.value || '',
+    packet_size: getNATFieldVal('pktsize', false),
+    random: getNATFieldVal('random', false),
+    icmp_options: getNATFieldVal('icmpoptions', false),
+    ipv4_options: getNATFieldVal('ipv4options', false),
+    ttl: getNATFieldVal('ttl', false),
+
+    // Extra tab
+    time: getNATFieldVal('time', false),
+    limit: getNATFieldVal('limit', false),
+    dst_limit: getNATFieldVal('dstlimit', false),
+    psd: getNATFieldVal('psd', false),
+
+    // Action tab
+    jump_target: action === 'jump' ? (document.getElementById('nat-modal-jumptarget')?.value || '') : '',
+    address_list: action.includes('address') ? (document.getElementById('nat-modal-action-addrlist')?.value || '') : '',
+    address_list_timeout: action.includes('address') ? (document.getElementById('nat-modal-action-timeout')?.value || '') : '',
+    log: document.getElementById('nat-modal-log')?.checked || false,
+    log_prefix: getNATFieldVal('logprefix', false)
+  };
 
   try {
     const res = await fetch(`${API_BASE}/firewall/nat`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        id,
-        chain,
-        action,
-        to_addresses: toaddr,
-        to_ports: toport,
-        protocol: proto,
-        src_address: src,
-        dst_address: dst,
-        dst_port: dport,
-        in_interface: inIface,
-        out_interface: outIface,
-        src_address_list: srclist,
-        dst_address_list: dstlist,
-        src_geoip: srcgeoip,
-        dst_geoip: dstgeoip,
-        comment,
-        disabled
-      })
+      body: JSON.stringify(payload)
     });
     if (res.ok) {
+      const data = await res.json();
       showToast('NAT Rule Saved', 'NAT mapping updated', 'success');
-      closeNATModal();
       loadFirewall(false);
+      if (closeAfter) {
+        closeNATModal();
+      } else {
+        if (data.id) {
+          document.getElementById('nat-modal-id').value = data.id;
+          const badge = document.getElementById('nat-modal-badge');
+          if (badge) badge.innerText = `#${data.id}`;
+        }
+      }
+    } else {
+      const err = await res.json();
+      showToast('Error', err.message || 'Failed to save rule', 'error');
     }
   } catch (err) {
     showToast('Error', err.message, 'error');
   }
 }
 
+function submitSaveNATRule(e) {
+  if (e && e.preventDefault) e.preventDefault();
+  return applyNATRule(true);
+}
+
+function startNATStatPolling() {
+  stopNATStatPolling();
+  pollNATRuleStats();
+  state.natStatInterval = setInterval(pollNATRuleStats, 1000);
+}
+
+function stopNATStatPolling() {
+  if (state.natStatInterval) {
+    clearInterval(state.natStatInterval);
+    state.natStatInterval = null;
+  }
+}
+
+async function pollNATRuleStats() {
+  const id = document.getElementById('nat-modal-id')?.value;
+  if (!id) {
+    renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory || new Array(30).fill(0), 'bps', 'nat-stat-legend-rate');
+    renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory || new Array(30).fill(0), 'p/s', 'nat-stat-legend-pktrate');
+    return;
+  }
+
+  try {
+    const res = await fetch(`${API_BASE}/firewall/nat`);
+    if (!res.ok) return;
+    const rules = await res.json();
+    const r = rules.find(x => x.id === id);
+    if (!r) return;
+
+    const now = Date.now();
+    const dt = Math.max(0.2, (now - (state.natLastTimestamp || now)) / 1000);
+    state.natLastTimestamp = now;
+
+    const curBytes = r.bytes || 0;
+    const curPackets = r.packets || 0;
+
+    const deltaBytes = Math.max(0, curBytes - (state.natLastBytes || curBytes));
+    const deltaPkts = Math.max(0, curPackets - (state.natLastPackets || curPackets));
+
+    state.natLastBytes = curBytes;
+    state.natLastPackets = curPackets;
+
+    const rateBps = Math.round((deltaBytes * 8) / dt);
+    const ratePkt = Math.round(deltaPkts / dt);
+
+    if (!state.natStatRateHistory) state.natStatRateHistory = new Array(30).fill(0);
+    if (!state.natStatPktHistory) state.natStatPktHistory = new Array(30).fill(0);
+
+    state.natStatRateHistory.push(rateBps);
+    if (state.natStatRateHistory.length > 30) state.natStatRateHistory.shift();
+
+    state.natStatPktHistory.push(ratePkt);
+    if (state.natStatPktHistory.length > 30) state.natStatPktHistory.shift();
+
+    const bEl = document.getElementById('nat-stat-bytes');
+    const pEl = document.getElementById('nat-stat-packets');
+    const rEl = document.getElementById('nat-stat-rate');
+    const prEl = document.getElementById('nat-stat-pktrate');
+
+    if (bEl) bEl.value = formatTraffic(curBytes).replace('/s', '');
+    if (pEl) pEl.value = curPackets.toLocaleString();
+    if (rEl) rEl.value = formatBps(rateBps);
+    if (prEl) prEl.value = `${ratePkt.toLocaleString()} p/s`;
+
+    renderFwSparkline('nat-stat-rate-svg', state.natStatRateHistory, 'bps', 'nat-stat-legend-rate');
+    renderFwSparkline('nat-stat-pktrate-svg', state.natStatPktHistory, 'p/s', 'nat-stat-legend-pktrate');
+  } catch (e) {
+    console.error('Error polling NAT rule stats', e);
+  }
+}
+
 async function moveNATRule(id, dir) {
   try {
     const res = await fetch(`${API_BASE}/firewall/nat/${encodeURIComponent(id)}/move`, {

Plik diff jest za duży
+ 861 - 120
internal/web/static/index.html


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików