Browse Source

feat(firewall): dedicated Source GEO IP List and Destination GEO IP List fields with inversion in Filter, NAT, and Mangle rules

Proxmox Router 1 week ago
parent
commit
b1950a65c4

+ 67 - 0
internal/firewall/geoip.go

@@ -500,3 +500,70 @@ func (m *GeoIPManager) GetCountrySubnets(code string, limit int) []string {
 	}
 	return res
 }
+
+// EnsureCountryOrPreset guarantees that the ipset mk_geo_<CODE> exists and has subnets loaded
+func (m *GeoIPManager) EnsureCountryOrPreset(code string) error {
+	code = strings.TrimPrefix(code, "!")
+	code = strings.TrimPrefix(code, "geo:")
+	code = strings.TrimPrefix(code, "geo_")
+	code = strings.ToUpper(strings.TrimSpace(code))
+	if code == "" {
+		return nil
+	}
+
+	// Check if it is a preset
+	var targetCodes []string
+	switch code {
+	case "EU":
+		targetCodes = []string{"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE"}
+	case "BALKANS":
+		targetCodes = []string{"GR", "CY", "AL", "BG", "RO", "RS", "MK", "ME", "BA", "HR", "SI", "TR"}
+	case "BLOCKLIST", "HIGHRISK":
+		targetCodes = []string{"CN", "RU", "IR", "KP", "BY"}
+	}
+
+	if len(targetCodes) > 0 {
+		setName := "mk_geo_" + code
+		var sb strings.Builder
+		sb.WriteString(fmt.Sprintf("create %s hash:net maxelem 262144 -exist\n", setName))
+		sb.WriteString(fmt.Sprintf("flush %s\n", setName))
+
+		for _, tc := range targetCodes {
+			zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(tc)+".zone")
+			if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
+				_, _ = m.SyncCountryZone(tc)
+			}
+			if f, err := os.Open(zoneFile); err == nil {
+				scanner := bufio.NewScanner(f)
+				for scanner.Scan() {
+					line := strings.TrimSpace(scanner.Text())
+					if line != "" && !strings.HasPrefix(line, "#") {
+						sb.WriteString(fmt.Sprintf("add %s %s -exist\n", setName, line))
+					}
+				}
+				f.Close()
+			}
+		}
+
+		cmd := exec.Command("ipset", "restore")
+		cmd.Stdin = strings.NewReader(sb.String())
+		return cmd.Run()
+	}
+
+	// Single country
+	zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
+	if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
+		if _, err := m.SyncCountryZone(code); err != nil {
+			return err
+		}
+	}
+
+	m.mu.Lock()
+	if c, ok := m.countries[code]; ok {
+		c.Enabled = true
+		_ = m.saveConfig()
+	}
+	m.mu.Unlock()
+
+	return m.ApplyCountryIPSet(code)
+}

+ 23 - 2
internal/firewall/ipset.go

@@ -5,8 +5,8 @@ import (
 	"strings"
 )
 
-// SyncAddressLists updates Linux ipsets to match the configured AddressList entries
-func SyncAddressLists(entries []AddressListEntry) error {
+// SyncAddressLists updates Linux ipsets to match the configured AddressList entries and referenced lists
+func SyncAddressLists(entries []AddressListEntry, referencedLists ...string) error {
 	// Group addresses by list name
 	listMap := make(map[string][]string)
 	for _, e := range entries {
@@ -16,6 +16,16 @@ func SyncAddressLists(entries []AddressListEntry) error {
 		listMap[e.Name] = append(listMap[e.Name], e.Address)
 	}
 
+	// Ensure any custom list explicitly referenced in rules exists even if empty
+	for _, ref := range referencedLists {
+		ref = strings.TrimPrefix(ref, "!")
+		if ref != "" && !strings.HasPrefix(ref, "geo:") && !strings.HasPrefix(ref, "geo_") {
+			if _, ok := listMap[ref]; !ok {
+				listMap[ref] = []string{}
+			}
+		}
+	}
+
 	// Clean up any mk_ sets that no longer exist or are now empty (ignoring mk_geo_ sets managed by GeoIP)
 	if out, err := exec.Command("ipset", "list", "-name").Output(); err == nil {
 		lines := strings.Split(string(out), "\n")
@@ -60,3 +70,14 @@ func GetIPSetName(listName string) string {
 	}
 	return "mk_" + strings.ReplaceAll(clean, " ", "_")
 }
+
+// GetGeoIPSetName returns the internal ipset name for a Geo-Location country or preset
+func GetGeoIPSetName(code string) string {
+	if code == "" {
+		return ""
+	}
+	clean := strings.TrimPrefix(code, "!")
+	clean = strings.TrimPrefix(clean, "geo:")
+	clean = strings.TrimPrefix(clean, "geo_")
+	return "mk_geo_" + strings.ToUpper(clean)
+}

+ 26 - 4
internal/firewall/iptables.go

@@ -176,7 +176,7 @@ func buildFilterRuleLine(r FilterRule) string {
 	args = append(args, fmt.Sprintf("-A %s", chain))
 
 	// Match criteria
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList)
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
 
 	// Comment ID for counters mapping & user comment
 	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
@@ -225,7 +225,7 @@ func buildNATRuleLine(r NATRule) string {
 	var args []string
 	args = append(args, fmt.Sprintf("-A %s", chain))
 
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, nil, "", "", r.SrcAddressList, r.DstAddressList)
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, nil, "", "", r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
 	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
 
 	act := strings.ToLower(r.Action)
@@ -276,7 +276,7 @@ func buildMangleRuleLine(r MangleRule) string {
 	var args []string
 	args = append(args, fmt.Sprintf("-A %s", chain))
 
-	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList)
+	args = appendMatchCriteria(args, r.SrcAddress, r.DstAddress, r.Protocol, r.SrcPort, r.DstPort, r.InInterface, r.OutInterface, r.ConnectionState, r.ConnectionMark, r.RoutingMark, r.SrcAddressList, r.DstAddressList, r.SrcGeoIP, r.DstGeoIP)
 	args = append(args, fmt.Sprintf("-m comment --comment \"id:%s\"", r.ID))
 
 	act := strings.ToLower(r.Action)
@@ -318,7 +318,7 @@ func buildMangleRuleLine(r MangleRule) string {
 	return strings.Join(args, " ")
 }
 
-func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPort, inIface, outIface string, ctStates []string, connMark, routingMark, srcList, dstList string) []string {
+func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPort, inIface, outIface string, ctStates []string, connMark, routingMark, srcList, dstList, srcGeoIP, dstGeoIP string) []string {
 	if srcAddr != "" {
 		if strings.HasPrefix(srcAddr, "!") {
 			args = append(args, fmt.Sprintf("! -s %s", strings.TrimPrefix(srcAddr, "!")))
@@ -415,6 +415,28 @@ func appendMatchCriteria(args []string, srcAddr, dstAddr, proto, srcPort, dstPor
 		args = append(args, fmt.Sprintf("-m set %s--match-set %s dst", invert, setName))
 	}
 
+	if srcGeoIP != "" {
+		invert := ""
+		cleanGeo := srcGeoIP
+		if strings.HasPrefix(cleanGeo, "!") {
+			invert = "! "
+			cleanGeo = strings.TrimPrefix(cleanGeo, "!")
+		}
+		setName := GetGeoIPSetName(cleanGeo)
+		args = append(args, fmt.Sprintf("-m set %s--match-set %s src", invert, setName))
+	}
+
+	if dstGeoIP != "" {
+		invert := ""
+		cleanGeo := dstGeoIP
+		if strings.HasPrefix(cleanGeo, "!") {
+			invert = "! "
+			cleanGeo = strings.TrimPrefix(cleanGeo, "!")
+		}
+		setName := GetGeoIPSetName(cleanGeo)
+		args = append(args, fmt.Sprintf("-m set %s--match-set %s dst", invert, setName))
+	}
+
 	return args
 }
 

+ 42 - 1
internal/firewall/manager.go

@@ -117,10 +117,51 @@ func (m *FirewallManager) Apply() error {
 	// Sync GeoIP country sets
 	if m.geoIPMgr != nil {
 		_ = m.geoIPMgr.ApplyAllEnabledSets()
+
+		// Also ensure any GeoIP lists explicitly referenced in active rules are loaded into ipset
+		for _, r := range m.filterRules {
+			if !r.Disabled {
+				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
+				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+			}
+		}
+		for _, r := range m.natRules {
+			if !r.Disabled {
+				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
+				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+			}
+		}
+		for _, r := range m.mangleRules {
+			if !r.Disabled {
+				if r.SrcGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.SrcGeoIP) }
+				if r.DstGeoIP != "" { _ = m.geoIPMgr.EnsureCountryOrPreset(r.DstGeoIP) }
+			}
+		}
+	}
+
+	// Collect any custom address lists referenced in active rules
+	var refLists []string
+	for _, r := range m.filterRules {
+		if !r.Disabled {
+			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
+			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
+		}
+	}
+	for _, r := range m.natRules {
+		if !r.Disabled {
+			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
+			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
+		}
+	}
+	for _, r := range m.mangleRules {
+		if !r.Disabled {
+			if r.SrcAddressList != "" { refLists = append(refLists, r.SrcAddressList) }
+			if r.DstAddressList != "" { refLists = append(refLists, r.DstAddressList) }
+		}
 	}
 
 	// Sync custom ipset address lists
-	_ = SyncAddressLists(m.addressLists)
+	_ = SyncAddressLists(m.addressLists, refLists...)
 
 	payload := BuildIPTablesRestore(m.filterRules, m.natRules, m.mangleRules)
 	return ApplyIPTables(payload)

+ 6 - 0
internal/firewall/types.go

@@ -22,6 +22,8 @@ type FilterRule struct {
 	RoutingMark     string   `json:"routing_mark,omitempty"`
 	SrcAddressList  string   `json:"src_address_list,omitempty"`
 	DstAddressList  string   `json:"dst_address_list,omitempty"`
+	SrcGeoIP        string   `json:"src_geoip,omitempty"`
+	DstGeoIP        string   `json:"dst_geoip,omitempty"`
 	Log             bool     `json:"log"`
 	LogPrefix       string   `json:"log_prefix,omitempty"`
 	Comment         string   `json:"comment,omitempty"`
@@ -47,6 +49,8 @@ type NATRule struct {
 	OutInterface   string `json:"out_interface,omitempty"`
 	SrcAddressList string `json:"src_address_list,omitempty"`
 	DstAddressList string `json:"dst_address_list,omitempty"`
+	SrcGeoIP       string `json:"src_geoip,omitempty"`
+	DstGeoIP       string `json:"dst_geoip,omitempty"`
 	Comment        string `json:"comment,omitempty"`
 	Disabled       bool   `json:"disabled"`
 	Packets        uint64 `json:"packets"`
@@ -77,6 +81,8 @@ type MangleRule struct {
 	RoutingMark        string   `json:"routing_mark,omitempty"`
 	SrcAddressList     string   `json:"src_address_list,omitempty"`
 	DstAddressList     string   `json:"dst_address_list,omitempty"`
+	SrcGeoIP           string   `json:"src_geoip,omitempty"`
+	DstGeoIP           string   `json:"dst_geoip,omitempty"`
 	Comment            string   `json:"comment,omitempty"`
 	Disabled           bool     `json:"disabled"`
 	Packets            uint64   `json:"packets"`

+ 171 - 43
internal/web/static/app.js

@@ -2235,8 +2235,8 @@ function renderFilterRules() {
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
       <td class="py-2.5 px-3 font-bold text-white">${r.chain}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.src_address || r.src_address_list ? (r.src_address || `list:${r.src_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || r.dst_address_list ? (r.dst_address || `list:${r.dst_address_list}`) : '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}</td>
       <td class="py-2.5 px-3 text-amber-400">${ports || '-'}</td>
       <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
@@ -2258,6 +2258,23 @@ function renderFilterRules() {
   });
 }
 
+function formatTarget(addr, list, geo) {
+  const parts = [];
+  if (addr) parts.push(`<span>${addr}</span>`);
+  if (list) {
+    const inv = list.startsWith('!') ? '!' : '';
+    const clean = list.replace(/^!/, '');
+    parts.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-950/80 border border-amber-800/40 text-amber-300 font-mono" title="Address List: ${list}">${inv}list:${clean}</span>`);
+  }
+  if (geo) {
+    const inv = geo.startsWith('!') ? '!' : '';
+    const clean = geo.replace(/^!/, '').replace(/^geo:/, '');
+    parts.push(`<span class="px-1.5 py-0.5 rounded text-[10px] bg-emerald-950/80 border border-emerald-800/40 text-emerald-300 font-mono" title="Geo-Location: ${geo}">🌍 ${inv}${clean}</span>`);
+  }
+  if (parts.length === 0) return '<span class="text-slate-600">0.0.0.0/0</span>';
+  return parts.join(' ');
+}
+
 function populateInterfaceSelect(selectId, currentVal = '') {
   const sel = document.getElementById(selectId);
   if (!sel) return;
@@ -2276,41 +2293,17 @@ function populateAddressListSelect(selectId, currentVal = '') {
   if (!sel) return;
   sel.innerHTML = '<option value="">(None)</option>';
 
-  const cleanVal = currentVal.startsWith('!') ? currentVal.substring(1) : currentVal;
-
-  // 1. Custom Address Lists
+  const cleanVal = (currentVal || '').replace(/^!/, '');
   const customNames = Array.from(new Set((state.addressLists || []).map(a => a.name))).sort();
-  if (customNames.length > 0) {
-    const optgroup = document.createElement('optgroup');
-    optgroup.label = 'Custom Address Lists';
-    customNames.forEach(name => {
-      const count = (state.addressLists || []).filter(a => a.name === name).length;
-      const opt = document.createElement('option');
-      opt.value = name;
-      opt.innerText = `${name} (${count} entries)`;
-      if (name === cleanVal) opt.selected = true;
-      optgroup.appendChild(opt);
-    });
-    sel.appendChild(optgroup);
-  }
-
-  // 2. Enabled Geo-Location Countries
-  const geoCountries = (state.geoCountries || []).filter(c => c.enabled);
-  if (geoCountries.length > 0) {
-    const optgroup = document.createElement('optgroup');
-    optgroup.label = '🌍 Geo-Location Countries';
-    geoCountries.forEach(c => {
-      const val = `geo:${c.code}`;
-      const opt = document.createElement('option');
-      opt.value = val;
-      opt.innerText = `${c.name} (${c.code}) - ${c.subnet_count.toLocaleString()} subnets`;
-      if (val === cleanVal || `geo_${c.code}` === cleanVal) opt.selected = true;
-      optgroup.appendChild(opt);
-    });
-    sel.appendChild(optgroup);
-  }
+  customNames.forEach(name => {
+    const count = (state.addressLists || []).filter(a => a.name === name).length;
+    const opt = document.createElement('option');
+    opt.value = name;
+    opt.innerText = `${name} (${count} entries)`;
+    if (name === cleanVal) opt.selected = true;
+    sel.appendChild(opt);
+  });
 
-  // Fallback: If cleanVal is set but not in list, add it as custom option
   if (cleanVal && !sel.querySelector(`option[value="${cleanVal}"]`)) {
     const opt = document.createElement('option');
     opt.value = cleanVal;
@@ -2320,6 +2313,45 @@ function populateAddressListSelect(selectId, currentVal = '') {
   }
 }
 
+function populateGeoIPSelect(selectId, currentVal = '') {
+  const sel = document.getElementById(selectId);
+  if (!sel) return;
+  sel.innerHTML = '<option value="">(None)</option>';
+
+  const cleanVal = (currentVal || '').replace(/^!/, '').replace(/^geo:/, '').toUpperCase();
+
+  // Presets optgroup
+  const grpPresets = document.createElement('optgroup');
+  grpPresets.label = '⚡ Regional Presets';
+  const presets = [
+    { code: 'EU', label: '🇪🇺 European Union (EU - 27 countries)' },
+    { code: 'BALKANS', label: '🏛️ Balkans (GR, CY, BG, RO, AL, etc.)' },
+    { code: 'BLOCKLIST', label: '🛡️ High-Risk Blocklist (CN, RU, IR, KP, BY)' }
+  ];
+  presets.forEach(p => {
+    const opt = document.createElement('option');
+    opt.value = p.code;
+    opt.innerText = p.label;
+    if (p.code === cleanVal) opt.selected = true;
+    grpPresets.appendChild(opt);
+  });
+  sel.appendChild(grpPresets);
+
+  // All Countries optgroup
+  const grpCountries = document.createElement('optgroup');
+  grpCountries.label = '🌍 All Countries';
+  const countries = (state.geoCountries || []).slice().sort((a, b) => a.name.localeCompare(b.name));
+  countries.forEach(c => {
+    const opt = document.createElement('option');
+    opt.value = c.code;
+    const subnetsInfo = c.subnet_count ? ` [${c.subnet_count.toLocaleString()} subnets]` : '';
+    opt.innerText = `${c.name} (${c.code})${subnetsInfo}`;
+    if (c.code === cleanVal) opt.selected = true;
+    grpCountries.appendChild(opt);
+  });
+  sel.appendChild(grpCountries);
+}
+
 function openAddFilterModal() {
   document.getElementById('filter-modal-title').innerText = 'Add Firewall Filter Rule';
   document.getElementById('filter-modal-id').value = '';
@@ -2341,8 +2373,15 @@ function openAddFilterModal() {
 
   populateInterfaceSelect('filter-modal-iniface');
   populateInterfaceSelect('filter-modal-outiface');
+  if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = false;
+  if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = false;
   populateAddressListSelect('filter-modal-srclist');
   populateAddressListSelect('filter-modal-dstlist');
+
+  if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = false;
+  if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = false;
+  populateGeoIPSelect('filter-modal-srcgeoip');
+  populateGeoIPSelect('filter-modal-dstgeoip');
   toggleFilterActionFields();
 
   document.getElementById('filter-modal').classList.remove('hidden');
@@ -2373,8 +2412,20 @@ function openEditFilterModal(id) {
 
   populateInterfaceSelect('filter-modal-iniface', r.in_interface);
   populateInterfaceSelect('filter-modal-outiface', r.out_interface);
-  populateAddressListSelect('filter-modal-srclist', r.src_address_list);
-  populateAddressListSelect('filter-modal-dstlist', r.dst_address_list);
+
+  const srcInv = (r.src_address_list || '').startsWith('!');
+  const dstInv = (r.dst_address_list || '').startsWith('!');
+  if (document.getElementById('filter-modal-srclist-inv')) document.getElementById('filter-modal-srclist-inv').checked = srcInv;
+  if (document.getElementById('filter-modal-dstlist-inv')) document.getElementById('filter-modal-dstlist-inv').checked = dstInv;
+  populateAddressListSelect('filter-modal-srclist', r.src_address_list || '');
+  populateAddressListSelect('filter-modal-dstlist', r.dst_address_list || '');
+
+  const srcGeoInv = (r.src_geoip || '').startsWith('!');
+  const dstGeoInv = (r.dst_geoip || '').startsWith('!');
+  if (document.getElementById('filter-modal-srcgeoip-inv')) document.getElementById('filter-modal-srcgeoip-inv').checked = srcGeoInv;
+  if (document.getElementById('filter-modal-dstgeoip-inv')) document.getElementById('filter-modal-dstgeoip-inv').checked = dstGeoInv;
+  populateGeoIPSelect('filter-modal-srcgeoip', r.src_geoip || '');
+  populateGeoIPSelect('filter-modal-dstgeoip', r.dst_geoip || '');
   toggleFilterActionFields();
 
   document.getElementById('filter-modal').classList.remove('hidden');
@@ -2406,8 +2457,25 @@ async function submitSaveFilterRule(e) {
   const inIface = document.getElementById('filter-modal-iniface').value;
   const outIface = document.getElementById('filter-modal-outiface').value;
   const connmark = document.getElementById('filter-modal-connmark').value.trim();
-  const srclist = document.getElementById('filter-modal-srclist').value;
-  const dstlist = document.getElementById('filter-modal-dstlist').value;
+
+  let srclist = document.getElementById('filter-modal-srclist')?.value || '';
+  if (srclist && document.getElementById('filter-modal-srclist-inv')?.checked) {
+    srclist = '!' + srclist.replace(/^!/, '');
+  }
+  let dstlist = document.getElementById('filter-modal-dstlist')?.value || '';
+  if (dstlist && document.getElementById('filter-modal-dstlist-inv')?.checked) {
+    dstlist = '!' + dstlist.replace(/^!/, '');
+  }
+
+  let srcgeoip = document.getElementById('filter-modal-srcgeoip')?.value || '';
+  if (srcgeoip && document.getElementById('filter-modal-srcgeoip-inv')?.checked) {
+    srcgeoip = '!' + srcgeoip.replace(/^!/, '');
+  }
+  let dstgeoip = document.getElementById('filter-modal-dstgeoip')?.value || '';
+  if (dstgeoip && document.getElementById('filter-modal-dstgeoip-inv')?.checked) {
+    dstgeoip = '!' + dstgeoip.replace(/^!/, '');
+  }
+
   const comment = document.getElementById('filter-modal-comment').value.trim();
   const disabled = document.getElementById('filter-modal-disabled').checked;
   const rejectWith = document.getElementById('filter-modal-rejectwith')?.value || '';
@@ -2438,6 +2506,8 @@ async function submitSaveFilterRule(e) {
         connection_mark: connmark,
         src_address_list: srclist,
         dst_address_list: dstlist,
+        src_geoip: srcgeoip,
+        dst_geoip: dstgeoip,
         comment,
         disabled
       })
@@ -2521,8 +2591,8 @@ function renderNATRules() {
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
       <td class="py-2.5 px-3 font-bold text-amber-400">${r.chain}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3 text-cyan-400 uppercase">${r.protocol || 'all'}${r.dst_port ? ':' + r.dst_port : ''}</td>
       <td class="py-2.5 px-3 text-emerald-400">${ifaces || '-'}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
@@ -2566,6 +2636,13 @@ function openAddNATModal() {
   if (srcInvEl) srcInvEl.checked = false;
   const dstInvEl = document.getElementById('nat-modal-dstlist-inv');
   if (dstInvEl) dstInvEl.checked = false;
+
+  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;
   toggleNATActionFields();
 
   document.getElementById('nat-modal').classList.remove('hidden');
@@ -2599,6 +2676,15 @@ function openEditNATModal(id) {
   if (dstInvEl) dstInvEl.checked = dstInv;
   populateAddressListSelect('nat-modal-srclist', r.src_address_list || '');
   populateAddressListSelect('nat-modal-dstlist', r.dst_address_list || '');
+
+  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();
 
   document.getElementById('nat-modal').classList.remove('hidden');
@@ -2641,6 +2727,7 @@ async function submitSaveNATRule(e) {
   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(/^!/, '');
@@ -2649,6 +2736,16 @@ async function submitSaveNATRule(e) {
   if (dstlist && document.getElementById('nat-modal-dstlist-inv')?.checked) {
     dstlist = '!' + dstlist.replace(/^!/, '');
   }
+
+  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 comment = document.getElementById('nat-modal-comment').value.trim();
   const disabled = document.getElementById('nat-modal-disabled').checked;
 
@@ -2670,6 +2767,8 @@ async function submitSaveNATRule(e) {
         out_interface: outIface,
         src_address_list: srclist,
         dst_address_list: dstlist,
+        src_geoip: srcgeoip,
+        dst_geoip: dstgeoip,
         comment,
         disabled
       })
@@ -2749,8 +2848,8 @@ function renderMangleRules() {
       <td class="py-2.5 px-3 text-center text-slate-500 font-bold">${r.order || idx + 1}</td>
       <td class="py-2.5 px-3 text-center">${renderRuleFlag(r.disabled, !!r.comment)}</td>
       <td class="py-2.5 px-3 font-bold text-purple-400">${r.chain}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.src_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
-      <td class="py-2.5 px-3 text-slate-300">${r.dst_address || '<span class="text-slate-600">0.0.0.0/0</span>'}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.src_address, r.src_address_list, r.src_geoip)}</td>
+      <td class="py-2.5 px-3 text-slate-300">${formatTarget(r.dst_address, r.dst_address_list, r.dst_geoip)}</td>
       <td class="py-2.5 px-3">${renderActionBadge(r.action)}</td>
       <td class="py-2.5 px-3 font-mono font-bold text-cyan-300">${markVal || '-'}</td>
       <td class="py-2.5 px-3 text-center">${r.passthrough ? '<span class="text-emerald-400">yes</span>' : '<span class="text-slate-500">no</span>'}</td>
@@ -2792,6 +2891,13 @@ function openAddMangleModal() {
   const dstInvEl = document.getElementById('mangle-modal-dstlist-inv');
   if (dstInvEl) dstInvEl.checked = false;
 
+  populateGeoIPSelect('mangle-modal-srcgeoip');
+  populateGeoIPSelect('mangle-modal-dstgeoip');
+  const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv');
+  if (srcGeoInvEl) srcGeoInvEl.checked = false;
+  const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv');
+  if (dstGeoInvEl) dstGeoInvEl.checked = false;
+
   document.getElementById('mangle-modal').classList.remove('hidden');
 }
 
@@ -2822,6 +2928,15 @@ function openEditMangleModal(id) {
   populateAddressListSelect('mangle-modal-srclist', r.src_address_list || '');
   populateAddressListSelect('mangle-modal-dstlist', r.dst_address_list || '');
 
+  const srcGeoInv = (r.src_geoip || '').startsWith('!');
+  const dstGeoInv = (r.dst_geoip || '').startsWith('!');
+  const srcGeoInvEl = document.getElementById('mangle-modal-srcgeoip-inv');
+  if (srcGeoInvEl) srcGeoInvEl.checked = srcGeoInv;
+  const dstGeoInvEl = document.getElementById('mangle-modal-dstgeoip-inv');
+  if (dstGeoInvEl) dstGeoInvEl.checked = dstGeoInv;
+  populateGeoIPSelect('mangle-modal-srcgeoip', r.src_geoip || '');
+  populateGeoIPSelect('mangle-modal-dstgeoip', r.dst_geoip || '');
+
   document.getElementById('mangle-modal').classList.remove('hidden');
 }
 
@@ -2843,6 +2958,7 @@ async function submitSaveMangleRule(e) {
   const src = document.getElementById('mangle-modal-src').value.trim();
   const dst = document.getElementById('mangle-modal-dst').value.trim();
   const inIface = document.getElementById('mangle-modal-iniface').value;
+
   let srclist = document.getElementById('mangle-modal-srclist')?.value || '';
   if (srclist && document.getElementById('mangle-modal-srclist-inv')?.checked) {
     srclist = '!' + srclist.replace(/^!/, '');
@@ -2851,6 +2967,16 @@ async function submitSaveMangleRule(e) {
   if (dstlist && document.getElementById('mangle-modal-dstlist-inv')?.checked) {
     dstlist = '!' + dstlist.replace(/^!/, '');
   }
+
+  let srcgeoip = document.getElementById('mangle-modal-srcgeoip')?.value || '';
+  if (srcgeoip && document.getElementById('mangle-modal-srcgeoip-inv')?.checked) {
+    srcgeoip = '!' + srcgeoip.replace(/^!/, '');
+  }
+  let dstgeoip = document.getElementById('mangle-modal-dstgeoip')?.value || '';
+  if (dstgeoip && document.getElementById('mangle-modal-dstgeoip-inv')?.checked) {
+    dstgeoip = '!' + dstgeoip.replace(/^!/, '');
+  }
+
   const connmark = document.getElementById('mangle-modal-connmark').value.trim();
   const comment = document.getElementById('mangle-modal-comment').value.trim();
   const disabled = document.getElementById('mangle-modal-disabled').checked;
@@ -2865,6 +2991,8 @@ async function submitSaveMangleRule(e) {
     in_interface: inIface,
     src_address_list: srclist,
     dst_address_list: dstlist,
+    src_geoip: srcgeoip,
+    dst_geoip: dstgeoip,
     connection_mark: connmark,
     comment,
     disabled

+ 96 - 3
internal/web/static/index.html

@@ -2527,18 +2527,57 @@
 
           <div class="grid grid-cols-2 gap-3">
             <div>
-              <label class="block font-semibold text-slate-300 mb-1">Src. Address List</label>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-slate-300">Src. Address List</label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="filter-modal-srclist-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
               <select id="filter-modal-srclist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
                 <option value="">(None)</option>
               </select>
             </div>
             <div>
-              <label class="block font-semibold text-slate-300 mb-1">Dst. Address List</label>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-slate-300">Dst. Address List</label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="filter-modal-dstlist-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
               <select id="filter-modal-dstlist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
                 <option value="">(None)</option>
               </select>
             </div>
           </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Source GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="filter-modal-srcgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="filter-modal-srcgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Destination GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="filter-modal-dstgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="filter-modal-dstgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+          </div>
         </div>
 
         <div>
@@ -2667,6 +2706,33 @@
               <select id="nat-modal-dstlist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
             </div>
           </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Source GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="nat-modal-srcgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="nat-modal-srcgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Destination GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="nat-modal-dstgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="nat-modal-dstgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+          </div>
         </div>
 
         <div>
@@ -2785,6 +2851,33 @@
               <select id="mangle-modal-dstlist" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono"></select>
             </div>
           </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Source GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="mangle-modal-srcgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="mangle-modal-srcgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+            <div>
+              <div class="flex items-center justify-between mb-1">
+                <label class="font-semibold text-emerald-400 flex items-center gap-1"><span>🌍</span> <span>Destination GEO IP List</span></label>
+                <label class="flex items-center space-x-1 cursor-pointer text-[10px] text-amber-400 font-mono">
+                  <input type="checkbox" id="mangle-modal-dstgeoip-inv" class="rounded bg-slate-800 border-slate-700">
+                  <span>! Invert</span>
+                </label>
+              </div>
+              <select id="mangle-modal-dstgeoip" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-white font-mono">
+                <option value="">(None)</option>
+              </select>
+            </div>
+          </div>
         </div>
 
         <div>
@@ -3691,6 +3784,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=2.7"></script>
+  <script src="app.js?v=2.8"></script>
 </body>
 </html>