| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694 |
- package firewall
- import (
- "bufio"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
- )
- const (
- GeoIPDir = "/etc/proxmox-router/firewall/geoip"
- GeoIPConfigFile = "/etc/proxmox-router/firewall/geoip/config.json"
- GeoIPZonesDir = "/etc/proxmox-router/firewall/geoip/zones"
- )
- // GeoIPConfig defines interval and enabled country persistence
- type GeoIPConfig struct {
- AutoUpdate bool `json:"auto_update"`
- IntervalHours int `json:"interval_hours"` // e.g. 24
- LastUpdated time.Time `json:"last_updated"`
- NextUpdate time.Time `json:"next_update"`
- EnabledCountries []string `json:"enabled_countries"`
- }
- // GeoIPManager handles country IP databases, ipset sync, and auto-update scheduling
- type GeoIPManager struct {
- mu sync.RWMutex
- config GeoIPConfig
- countries map[string]*GeoCountryInfo
- timer *time.Timer
- stopCh chan struct{}
- restoreDone chan struct{}
- isUpdating bool
- lastError string
- referenced func(string) bool
- syncMu sync.Mutex
- }
- func NewGeoIPManager() *GeoIPManager {
- _ = os.MkdirAll(GeoIPZonesDir, 0755)
- m := &GeoIPManager{
- countries: make(map[string]*GeoCountryInfo),
- stopCh: make(chan struct{}),
- restoreDone: make(chan struct{}),
- }
- // 1. Initialize country metadata
- for _, c := range StandardISOCountries {
- info := c
- info.IPSetName = "mk_geo_" + c.Code
- m.countries[c.Code] = &info
- }
- // 2. Load Config
- m.loadConfig()
- // 3. Scan cached zone files
- m.scanCachedZones()
- // Restore cached enabled countries without hiding a failed restore.
- go func() {
- defer close(m.restoreDone)
- m.mu.RLock()
- var enabled []string
- for code, c := range m.countries {
- if c.Enabled {
- enabled = append(enabled, code)
- }
- }
- m.mu.RUnlock()
- var failures []error
- for _, code := range enabled {
- zone := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
- if _, err := os.Stat(zone); os.IsNotExist(err) {
- if _, err = m.SyncCountryZone(code); err != nil {
- failures = append(failures, fmt.Errorf("%s: %w", code, err))
- }
- } else if err := m.ApplyCountryIPSet(code); err != nil {
- failures = append(failures, fmt.Errorf("%s: %w", code, err))
- }
- }
- if err := errors.Join(failures...); err != nil {
- m.mu.Lock()
- m.lastError = err.Error()
- m.mu.Unlock()
- fmt.Fprintf(os.Stderr, "GeoIP restore: %v\n", err)
- }
- }()
- // 5. Start background auto-update scheduler
- m.startScheduler()
- return m
- }
- func (m *GeoIPManager) loadConfig() {
- m.config = GeoIPConfig{
- AutoUpdate: true,
- IntervalHours: 24,
- EnabledCountries: []string{"GR"},
- }
- if data, err := os.ReadFile(GeoIPConfigFile); err == nil {
- if err := json.Unmarshal(data, &m.config); err != nil {
- m.lastError = "Invalid GeoIP configuration: " + err.Error()
- m.config.AutoUpdate = false
- m.config.EnabledCountries = nil
- }
- }
- if m.config.IntervalHours <= 0 {
- m.config.IntervalHours = 24
- }
- // Mark enabled countries
- for _, code := range m.config.EnabledCountries {
- if c, ok := m.countries[strings.ToUpper(code)]; ok {
- c.Enabled = true
- }
- }
- }
- func (m *GeoIPManager) saveConfig() error {
- if err := os.MkdirAll(GeoIPDir, 0755); err != nil {
- return err
- }
- m.config.EnabledCountries = make([]string, 0)
- for code, c := range m.countries {
- if c.Enabled {
- m.config.EnabledCountries = append(m.config.EnabledCountries, code)
- }
- }
- sort.Strings(m.config.EnabledCountries)
- data, err := json.MarshalIndent(m.config, "", " ")
- if err != nil {
- return err
- }
- return writeAtomic(GeoIPConfigFile, data)
- }
- func (m *GeoIPManager) scanCachedZones() {
- for code, c := range m.countries {
- zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
- if stat, err := os.Stat(zoneFile); err == nil && stat.Size() > 0 {
- c.LastUpdated = stat.ModTime()
- if count, err := countLines(zoneFile); err == nil {
- c.SubnetCount = count
- }
- }
- }
- }
- func countLines(path string) (int, error) {
- f, err := os.Open(path)
- if err != nil {
- return 0, err
- }
- defer f.Close()
- count := 0
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line != "" && !strings.HasPrefix(line, "#") {
- count++
- }
- }
- return count, scanner.Err()
- }
- func (m *GeoIPManager) startScheduler() {
- m.mu.Lock()
- defer m.mu.Unlock()
- if m.timer != nil {
- m.timer.Stop()
- }
- if !m.config.AutoUpdate {
- return
- }
- interval := time.Duration(m.config.IntervalHours) * time.Hour
- now := time.Now()
- // Calculate delay until next run
- var delay time.Duration
- if m.config.LastUpdated.IsZero() {
- delay = 1 * time.Minute
- } else {
- next := m.config.LastUpdated.Add(interval)
- if next.After(now) {
- delay = next.Sub(now)
- } else {
- delay = 10 * time.Second
- }
- }
- m.config.NextUpdate = now.Add(delay)
- m.timer = time.AfterFunc(delay, func() {
- _ = m.UpdateNow()
- // Re-arm for next full interval
- m.startScheduler()
- })
- }
- // ListCountries returns all countries sorted by Name with Greece & Cyprus first
- func (m *GeoIPManager) ListCountries() []GeoCountryInfo {
- m.mu.RLock()
- defer m.mu.RUnlock()
- res := make([]GeoCountryInfo, 0, len(m.countries))
- for _, c := range m.countries {
- res = append(res, *c)
- }
- sort.Slice(res, func(i, j int) bool {
- // Put enabled countries at the top, then alphabetically
- if res[i].Enabled != res[j].Enabled {
- return res[i].Enabled
- }
- if res[i].Code == "GR" {
- return true
- }
- if res[j].Code == "GR" {
- return false
- }
- return res[i].Name < res[j].Name
- })
- return res
- }
- func (m *GeoIPManager) GetStatus() map[string]any {
- m.mu.RLock()
- defer m.mu.RUnlock()
- enabledCount := 0
- totalSubnets := 0
- for _, c := range m.countries {
- if c.Enabled {
- enabledCount++
- totalSubnets += c.SubnetCount
- }
- }
- restoring := false
- if m.restoreDone != nil {
- select {
- case <-m.restoreDone:
- default:
- restoring = true
- }
- }
- return map[string]any{
- "auto_update": m.config.AutoUpdate,
- "interval_hours": m.config.IntervalHours,
- "last_updated": m.config.LastUpdated,
- "next_update": m.config.NextUpdate,
- "enabled_countries": m.config.EnabledCountries,
- "enabled_count": enabledCount,
- "total_subnets": totalSubnets,
- "is_updating": m.isUpdating,
- "is_restoring": restoring,
- "last_error": m.lastError,
- }
- }
- func (m *GeoIPManager) SetConfig(auto bool, hours int) error {
- m.mu.Lock()
- old := m.config
- if hours < 1 || hours > 8760 {
- m.mu.Unlock()
- return fmt.Errorf("interval must be 1–8760 hours")
- }
- m.config.AutoUpdate = auto
- m.config.IntervalHours = hours
- if err := m.saveConfig(); err != nil {
- m.config = old
- m.mu.Unlock()
- return err
- }
- m.mu.Unlock()
- m.startScheduler()
- return nil
- }
- func (m *GeoIPManager) ToggleCountry(code string, enable bool) error {
- code = strings.ToUpper(code)
- m.mu.RLock()
- _, ok := m.countries[code]
- m.mu.RUnlock()
- if !ok {
- return fmt.Errorf("unknown country %q", code)
- }
- if !enable && m.referenced != nil && m.referenced(code) {
- return fmt.Errorf("country %s is used by an active firewall rule; change that rule first", code)
- }
- if enable {
- if _, err := os.Stat(filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")); os.IsNotExist(err) {
- if _, err = m.SyncCountryZone(code); err != nil {
- return err
- }
- }
- if err := m.ApplyCountryIPSet(code); err != nil {
- return err
- }
- } else {
- name := GetGeoIPSetName(code)
- if output, err := exec.Command("ipset", "list", name, "-terse").CombinedOutput(); err == nil {
- if err := runIPSet("destroy", name); err != nil {
- return fmt.Errorf("GeoIP set is referenced; it was preserved: %w", err)
- }
- } else if !strings.Contains(string(output), "does not exist") {
- return fmt.Errorf("cannot inspect GeoIP set: %s: %w", output, err)
- }
- }
- m.mu.Lock()
- c := m.countries[code]
- old := c.Enabled
- oldConfig := m.config
- c.Enabled = enable
- if err := m.saveConfig(); err != nil {
- c.Enabled = old
- m.config = oldConfig
- m.mu.Unlock()
- if !enable && old {
- if restoreErr := m.ApplyCountryIPSet(code); restoreErr != nil {
- return fmt.Errorf("persist: %v; restore set: %w", err, restoreErr)
- }
- }
- return err
- }
- m.mu.Unlock()
- return nil
- }
- func (m *GeoIPManager) BatchToggle(preset string, enable bool, customCodes []string) error {
- var targetCodes []string
- switch strings.ToUpper(preset) {
- 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"}
- case "ALL":
- m.mu.RLock()
- for code := range m.countries {
- targetCodes = append(targetCodes, code)
- }
- m.mu.RUnlock()
- case "NONE":
- m.mu.RLock()
- for code, c := range m.countries {
- if c.Enabled {
- targetCodes = append(targetCodes, code)
- }
- }
- m.mu.RUnlock()
- enable = false
- default:
- targetCodes = customCodes
- }
- if len(targetCodes) == 0 && strings.ToUpper(preset) != "NONE" {
- return fmt.Errorf("no countries selected or unknown preset")
- }
- for _, code := range targetCodes {
- m.mu.RLock()
- _, ok := m.countries[strings.ToUpper(code)]
- m.mu.RUnlock()
- if !ok {
- return fmt.Errorf("unknown country %q", code)
- }
- if !enable && m.referenced != nil && m.referenced(strings.ToUpper(code)) {
- return fmt.Errorf("country %s is used by an active firewall rule", code)
- }
- }
- var failures []error
- for _, code := range targetCodes {
- if err := m.ToggleCountry(code, enable); err != nil {
- failures = append(failures, fmt.Errorf("%s: %w", code, err))
- }
- }
- return errors.Join(failures...)
- }
- // SyncCountryZone downloads the country zone file from ipdeny and refreshes ipset
- func (m *GeoIPManager) SyncCountryZone(code string) (int, error) {
- m.syncMu.Lock()
- defer m.syncMu.Unlock()
- m.mu.RLock()
- _, valid := m.countries[strings.ToUpper(code)]
- m.mu.RUnlock()
- if !valid {
- return 0, fmt.Errorf("unknown country %q", code)
- }
- code = strings.ToUpper(code)
- url := fmt.Sprintf("https://www.ipdeny.com/ipblocks/data/countries/%s.zone", strings.ToLower(code))
- client := &http.Client{Timeout: 15 * time.Second}
- req, err := http.NewRequest("GET", url, nil)
- if err != nil {
- return 0, err
- }
- req.Header.Set("User-Agent", "Proxmox-Router/1.0")
- resp, err := client.Do(req)
- if err != nil {
- return 0, fmt.Errorf("failed to fetch zone for %s: %w", code, err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return 0, fmt.Errorf("ipdeny returned HTTP %d for %s", resp.StatusCode, code)
- }
- zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
- out, err := os.CreateTemp(GeoIPZonesDir, ".zone-*")
- tmpFile := ""
- if out != nil {
- tmpFile = out.Name()
- }
- defer os.Remove(tmpFile)
- if err != nil {
- return 0, err
- }
- if _, err := io.Copy(out, io.LimitReader(resp.Body, 32*1024*1024+1)); err != nil {
- out.Close()
- _ = os.Remove(tmpFile)
- return 0, err
- }
- if err := out.Close(); err != nil {
- return 0, err
- }
- data, err := os.ReadFile(tmpFile)
- if err != nil {
- return 0, err
- }
- if len(data) > 32*1024*1024 {
- return 0, fmt.Errorf("GeoIP download too large")
- }
- countValid := 0
- for _, line := range strings.Split(string(data), "\n") {
- line = strings.TrimSpace(line)
- if line == "" || strings.HasPrefix(line, "#") {
- continue
- }
- ip, _, e := net.ParseCIDR(line)
- if e != nil || ip.To4() == nil {
- return 0, fmt.Errorf("invalid IPv4 CIDR in country download")
- }
- countValid++
- }
- if countValid == 0 {
- return 0, fmt.Errorf("empty country download")
- }
- if err := os.Rename(tmpFile, zoneFile); err != nil {
- return 0, err
- }
- count, _ := countLines(zoneFile)
- m.mu.Lock()
- if c, ok := m.countries[code]; ok {
- c.SubnetCount = count
- c.LastUpdated = time.Now()
- }
- m.mu.Unlock()
- // If enabled, load into kernel ipset immediately
- m.mu.RLock()
- enabled := m.countries[code] != nil && m.countries[code].Enabled
- m.mu.RUnlock()
- if enabled {
- if err := m.ApplyCountryIPSet(code); err != nil {
- return 0, err
- }
- }
- return count, nil
- }
- // ApplyCountryIPSet uses high-speed `ipset restore` to load the country's subnets
- func (m *GeoIPManager) ApplyCountryIPSet(code string) error {
- m.mu.RLock()
- _, valid := m.countries[strings.ToUpper(code)]
- m.mu.RUnlock()
- if !valid {
- return fmt.Errorf("unknown country %q", code)
- }
- code = strings.ToUpper(code)
- zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
- f, err := os.Open(zoneFile)
- if err != nil {
- return err
- }
- defer f.Close()
- setName := "mk_geo_" + code
- var addresses []string
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line != "" && !strings.HasPrefix(line, "#") {
- addresses = append(addresses, line)
- }
- }
- if err := scanner.Err(); err != nil {
- return err
- }
- return replaceSet(setName, "inet", addresses)
- }
- // ApplyAllEnabledSets ensures all enabled countries are present in kernel ipset
- func (m *GeoIPManager) ApplyAllEnabledSets() error {
- m.mu.RLock()
- var enabled []string
- for code, c := range m.countries {
- if c.Enabled {
- enabled = append(enabled, code)
- }
- }
- m.mu.RUnlock()
- for _, code := range enabled {
- if err := m.ApplyCountryIPSet(code); err != nil {
- return err
- }
- }
- return nil
- }
- // UpdateNow updates zone files for all currently enabled countries
- func (m *GeoIPManager) UpdateNow() error {
- m.mu.Lock()
- if m.isUpdating {
- m.mu.Unlock()
- return fmt.Errorf("update already in progress")
- }
- m.isUpdating = true
- m.lastError = ""
- var enabled []string
- for code, c := range m.countries {
- if c.Enabled {
- enabled = append(enabled, code)
- }
- }
- m.mu.Unlock()
- go func() {
- var failures []error
- for _, code := range enabled {
- if _, err := m.SyncCountryZone(code); err != nil {
- failures = append(failures, fmt.Errorf("%s: %w", code, err))
- }
- }
- m.mu.Lock()
- defer m.mu.Unlock()
- m.isUpdating = false
- m.config.NextUpdate = time.Now().Add(time.Duration(m.config.IntervalHours) * time.Hour)
- if len(failures) == 0 {
- m.config.LastUpdated = time.Now()
- }
- if err := m.saveConfig(); err != nil {
- failures = append(failures, err)
- }
- if err := errors.Join(failures...); err != nil {
- m.lastError = err.Error()
- }
- }()
- return nil
- }
- // GetCountrySubnets returns first N subnets from cached zone file for preview
- func (m *GeoIPManager) GetCountrySubnets(code string, limit int) []string {
- m.mu.RLock()
- _, valid := m.countries[strings.ToUpper(code)]
- m.mu.RUnlock()
- if !valid {
- return nil
- }
- code = strings.ToUpper(code)
- zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(code)+".zone")
- f, err := os.Open(zoneFile)
- if err != nil {
- return nil
- }
- defer f.Close()
- if limit <= 0 {
- limit = 100
- }
- var res []string
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := strings.TrimSpace(scanner.Text())
- if line != "" && !strings.HasPrefix(line, "#") {
- res = append(res, line)
- if len(res) >= limit {
- break
- }
- }
- }
- 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 addresses []string
- for _, country := range targetCodes {
- zoneFile := filepath.Join(GeoIPZonesDir, strings.ToLower(country)+".zone")
- if _, err := os.Stat(zoneFile); os.IsNotExist(err) {
- if _, err = m.SyncCountryZone(country); err != nil {
- return err
- }
- }
- data, err := os.ReadFile(zoneFile)
- if err != nil {
- return err
- }
- for _, line := range strings.Split(string(data), "\n") {
- line = strings.TrimSpace(line)
- if line != "" && !strings.HasPrefix(line, "#") {
- addresses = append(addresses, line)
- }
- }
- }
- return replaceSet(setName, "inet", addresses)
- }
- // 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
- if err := m.saveConfig(); err != nil {
- m.mu.Unlock()
- return err
- }
- }
- m.mu.Unlock()
- return m.ApplyCountryIPSet(code)
- }
|