/* * Amsterdam Web Communities System * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * SPDX-License-Identifier: MPL-2.0 */ // The database package contains database management and storage logic. package database import ( "context" "database/sql" "errors" "fmt" "math/big" "net" "slices" "sync" "time" "git.erbosoft.com/amy/amsterdam/config" log "github.com/sirupsen/logrus" ) // IPBanEntry represents an IP address banned from the system. type IPBanEntry struct { Id int32 `db:"id"` // unique ID of the ban structure AddressLow uint64 `db:"address_lo"` // IP address (low bits) AddressHigh uint64 `db:"address_hi"` // IP address (high bits) MaskLow uint64 `db:"mask_lo"` // Address mask (low bits) MaskHigh uint64 `db:"mask_hi"` // Address mask (high bits) Enable bool `db:"enable"` // is this ban enabled? Expire *time.Time `db:"expire"` // when does the ban expire? Message string `db:"message"` // message to display for ban BlockByUid int32 `db:"block_by"` // who blocked this IP? BlockOn time.Time `db:"block_on"` // when was it blocked? } // low64mask is bigint 0xFFFFFFFFFFFFFFFF, used in splitting large addresses. var low64mask *big.Int // knownBans is a cache of known banned addresses. var knownBans map[string]string // knownGood is a cache of known good IP addresses. var knownGood map[string]bool // banMutex synchronizes access to our cache. var banMutex sync.Mutex // init initializes the internals in this file. func init() { a := big.NewInt(1) b := big.NewInt(0).Lsh(a, 64) low64mask = big.NewInt(0).Sub(b, big.NewInt(1)) knownBans = make(map[string]string) knownGood = make(map[string]bool) } // sweepentry is a structure used to communicate with the ban sweeper. type sweepentry struct { expire time.Time // expiration time address string // IP address in question } // banSweeper is a goroutine that sweeps the banned IP address cache, looking for entries that have expired // and kicking them out so the database can be rechecked. func banSweeper(ctx context.Context, done chan bool, resetMe chan bool, input chan *sweepentry) { expireTab := make([]*sweepentry, 0) // table of expiring entries running := true var topEntry *sweepentry = nil // always points to the top of expireTab var checkTimer *time.Timer = nil // timer indicating when topEntry expires for running { if checkTimer == nil { // "timer idle" mode select { case <-ctx.Done(): running = false case <-resetMe: // this is bupkis, because we're resetting nothing topEntry = nil checkTimer = nil case entry := <-input: // got an initial entry, set the expire timer up expireTab = append(expireTab, entry) topEntry = entry checkTimer = time.NewTimer(time.Until(entry.expire)) } } else { // "timer active" mode select { case <-ctx.Done(): running = false case <-resetMe: // clear out everything on a reset signal checkTimer.Stop() expireTab = make([]*sweepentry, 0) topEntry = nil checkTimer = nil case entry := <-input: // Add new entry to expiretab. Table is always sorted by expire date. expireTab = append(expireTab, entry) slices.SortFunc(expireTab, func(a, b *sweepentry) int { return a.expire.Compare(b.expire) }) if topEntry != expireTab[0] { // we have a new top entry! reset the timer topEntry = expireTab[0] checkTimer.Reset(time.Until(topEntry.expire)) } case <-checkTimer.C: // expiry timer fired! kick it out of the known bans hash banMutex.Lock() delete(knownBans, topEntry.address) banMutex.Unlock() if len(expireTab) > 1 { // got a new top entry, reset the timer expireTab = expireTab[1:] topEntry = expireTab[0] checkTimer.Reset(time.Until(topEntry.expire)) } else { // no more entries, go back to "timer idle" mode expireTab = make([]*sweepentry, 0) topEntry = nil checkTimer = nil } } } } done <- true // signal that we're done } // banSweeperReset tells the ban sweeper to clear itself. var banSweeperReset chan bool // banSweeperInput is the input channel where we feed new entries to the ban sweeper. var banSweeperInput chan *sweepentry // setupIPBanSweep sets up the IP ban sweeper routine, and returns a function that tears it down. func setupIPBanSweep() func() { ctx, cancel := context.WithCancel(context.Background()) banSweeperReset = make(chan bool) banSweeperInput = make(chan *sweepentry, config.GlobalConfig.Tuning.Queues.IPBans) done := make(chan bool) go banSweeper(ctx, done, banSweeperReset, banSweeperInput) return func() { cancel() <-done } } // nukeIPBanCache completely clears the IP ban cache. func nukeIPBanCache(bad, good bool) { banMutex.Lock() defer banMutex.Unlock() if bad { banSweeperReset <- true // send the reset signal to the sweeper clear(knownBans) } if good { clear(knownGood) } } // IsV4 returns true if the entry's address is an IPv4 address. func (ipb *IPBanEntry) IsV4() bool { ip := AmCombineIP(ipb.AddressLow, ipb.AddressHigh) return ip.To4() != nil } // SetEnable sets the enable flag on an IP ban entry. func (ipb *IPBanEntry) SetEnable(ctx context.Context, flag bool) error { if flag == ipb.Enable { return nil } _, err := amdb.ExecContext(ctx, "UPDATE ipban SET enable = ? WHERE id = ?", flag, ipb.Id) if err == nil { nukeIPBanCache(ipb.Enable, !ipb.Enable) ipb.Enable = flag } return err } // Delete deletes an IP ban entry. func (ipb *IPBanEntry) Delete(ctx context.Context) error { _, err := amdb.ExecContext(ctx, "DELETE FROM ipban WHERE id = ?", ipb.Id) if err == nil { nukeIPBanCache(true, false) // can only affect "ban" cache entries, not "good" ones } return err } // AmCombineIP converts an IP address, in terms of low and high 64-bit values, to a net.IP value. func AmCombineIP(low, high uint64) net.IP { t := big.NewInt(0).Lsh(new(big.Int).SetUint64(high), 64) addr := big.NewInt(0).Or(t, new(big.Int).SetUint64(low)) return net.IP(addr.FillBytes(make([]byte, 16))) } // AmIPToString converts an IP address, in terms of low and high 64-bit values, to a string. func AmIPToString(low, high uint64, asV4 bool) string { ip := AmCombineIP(low, high) if asV4 { ip = ip[12:16] } return ip.String() } // AmBreakUpIP breaks up the IP address into low and high uint64 values. func AmBreakUpIP(addr net.IP) (uint64, uint64) { iv := big.NewInt(0).SetBytes(addr) ivLo := big.NewInt(0).And(iv, low64mask).Uint64() ivHi := big.NewInt(0).Rsh(iv, 64).Uint64() return ivLo, ivHi } /* AmTestIPBan tests an IP address to see if it's on the banned list. * Parameters: * ctx - Standard Go context parameter. * ip_address - The IP address to be tested. * Returns: * Ban message if the address is banned, or empty string if it isn't. * Standard Go error status. */ func AmTestIPBan(ctx context.Context, ipAddress string) (string, error) { banMutex.Lock() defer banMutex.Unlock() rc := knownBans[ipAddress] if rc != "" { return rc, nil } if knownGood[ipAddress] { return "", nil } addr := net.ParseIP(ipAddress) if addr == nil { return "", fmt.Errorf("invalid address %s", ipAddress) } ivLo, ivHi := AmBreakUpIP(addr) row := amdb.QueryRowContext(ctx, `SELECT message, expire FROM ipban WHERE (address_lo & mask_lo) = (? & mask_lo) AND (address_hi & mask_hi) = (? & mask_hi) AND (expire IS NULL OR expire >= NOW()) AND enable <> 0 ORDER BY mask_hi DESC, mask_lo DESC`, ivLo, ivHi) var expire *time.Time = nil err := row.Scan(&rc, &expire) switch err { case nil: knownBans[ipAddress] = rc if expire != nil { // set up so that this entry gets removed when it expires banSweeperInput <- &sweepentry{expire: *expire, address: ipAddress} } return rc, nil case sql.ErrNoRows: knownGood[ipAddress] = true return "", nil } return "", err } // AmListIPBans gets a listing of IP address bans. func AmListIPBans(ctx context.Context) ([]IPBanEntry, error) { var rc []IPBanEntry err := amdb.SelectContext(ctx, &rc, "SELECT * FROM ipban") return rc, err } // AmGetIPBan returns a single IP address ban structure. func AmGetIPBan(ctx context.Context, id int32) (*IPBanEntry, error) { ban := new(IPBanEntry) if err := amdb.GetContext(ctx, ban, "SELECT * FROM ipban WHERE id = ?", id); err != nil { return nil, err } return ban, nil } // AmAddIPBan adds a new IP address ban. func AmAddIPBan(ctx context.Context, addr, mask net.IP, expires *time.Time, message string, byUser *User) error { log.Debugf("AmAddIPBan: addr = %s (%v), mask = %s(%v)", addr.String(), addr, mask.String(), mask) if addr.IsUnspecified() { return errors.New("cannot add IP ban with unspecified address") } if mask.IsUnspecified() { return errors.New("cannot add IP ban with unspecified mask") } newEntry := IPBanEntry{ Expire: expires, Message: message, BlockByUid: byUser.Uid, } newEntry.AddressLow, newEntry.AddressHigh = AmBreakUpIP(addr) if newEntry.AddressLow == 0 && newEntry.AddressHigh == 0 { return errors.New("invalid or incorrectly-parsed address") } newEntry.MaskLow, newEntry.MaskHigh = AmBreakUpIP(mask) if newEntry.MaskLow == 0 && newEntry.MaskHigh == 0 { return errors.New("invalid or incorrectly-parsed mask") } _, err := amdb.NamedExecContext(ctx, `INSERT INTO ipban (address_lo, address_hi, mask_lo, mask_hi, enable, expire, message, block_by, block_on) VALUES (:address_lo, :address_hi, :mask_lo, :mask_hi, 1, :expire, :message, :block_by, NOW())`, &newEntry) if err == nil { nukeIPBanCache(false, true) } return err }