diff --git a/database/base.go b/database/base.go index 71d9a83..373743e 100644 --- a/database/base.go +++ b/database/base.go @@ -1,6 +1,6 @@ /* * Amsterdam Web Communities System - * Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved + * 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 @@ -21,12 +21,17 @@ var amdb *sqlx.DB // SetupDb sets up the database and associated items. func SetupDb() (func(), error) { var fn1 func() = nil + var fn2 func() = nil db, err := sqlx.Open(config.GlobalConfig.Database.Driver, config.GlobalConfig.Database.Dsn) if err == nil { amdb = db fn1 = setupAuditWriter() + fn2 = setupIPBanSweep() } return func() { + if fn2 != nil { + fn2() + } if fn1 != nil { fn1() } diff --git a/database/ipban.go b/database/ipban.go index 4c3598e..4f270c7 100644 --- a/database/ipban.go +++ b/database/ipban.go @@ -12,13 +12,29 @@ package database import ( "context" "database/sql" + "errors" "fmt" "math/big" "net" + "slices" "sync" "time" ) +// 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 @@ -40,6 +56,119 @@ func init() { 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(done chan bool, ended 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 <-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 <-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 + } + } + } + } + ended <- 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() { + banSweeperReset = make(chan bool) + banSweeperInput = make(chan *sweepentry, 32) + done := make(chan bool) + ended := make(chan bool) + go banSweeper(done, ended, banSweeperReset, banSweeperInput) + return func() { + done <- true + <-ended + } +} + +// nukeIPBanCache completely clears the IP ban cache. +func nukeIPBanCache() { + banMutex.Lock() + defer banMutex.Unlock() + banSweeperReset <- true // send the reset signal to the sweeper + for k := range knownBans { + delete(knownBans, k) + } + for k := range knownGood { + delete(knownGood, k) + } +} + +// AmIPToString converts an IP addrsss, in terms of low and high 64-bit values, to a string. +func AmIPToString(low, high uint64) string { + t := big.NewInt(0).Lsh(new(big.Int).SetUint64(high), 64) + addr := big.NewInt(0).Or(t, new(big.Int).SetUint64(low)) + ip := net.IP(addr.FillBytes(make([]byte, 16))) + return ip.String() +} + /* AmTestIPBan tests an IP address to see if it's on the banned list. * Parameters: * ctx - Standard Go context parameter. @@ -48,35 +177,62 @@ func init() { * Ban message if the address is banned, or empty string if it isn't. * Standard Go error status. */ -func AmTestIPBan(ctx context.Context, ip_address string) (string, error) { +func AmTestIPBan(ctx context.Context, ipAddress string) (string, error) { banMutex.Lock() defer banMutex.Unlock() - rc := knownBans[ip_address] + rc := knownBans[ipAddress] if rc != "" { return rc, nil } - if knownGood[ip_address] { + if knownGood[ipAddress] { return "", nil } - addr := net.ParseIP(ip_address) + addr := net.ParseIP(ipAddress) if addr == nil { - return "", fmt.Errorf("invalid address %s", ip_address) + return "", fmt.Errorf("invalid address %s", ipAddress) } iv := big.NewInt(0) iv.SetBytes(addr) - iv_lo := big.NewInt(0).And(iv, low64mask).Uint64() - iv_hi := big.NewInt(0).Rsh(iv, 64).Uint64() - row := amdb.QueryRowContext(ctx, `SELECT message FROM ipban WHERE (address_lo & mask_lo) = (? & mask_lo) - AND (address_hi & mask_hi) = (? & mask_hi) AND (expire IS NULL OR expire >= ?) - AND enable <> 0 ORDER BY mask_hi DESC, mask_lo DESC`, iv_lo, iv_hi, time.Now().UTC()) - err := row.Scan(&rc) + ivLo := big.NewInt(0).And(iv, low64mask).Uint64() + ivHi := big.NewInt(0).Rsh(iv, 64).Uint64() + 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[ip_address] = rc + 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[ip_address] = true + 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) { + var dbdata []IPBanEntry + err := amdb.SelectContext(ctx, &dbdata, "SELECT * FROM ipban WHERE id = ?", id) + if err != nil { + return nil, err + } + if len(dbdata) == 0 { + return nil, errors.New("not found") + } else if len(dbdata) > 1 { + return nil, errors.New("internal error, too many returns") + } + return &(dbdata[0]), nil +} diff --git a/main.go b/main.go index c8993fe..caf46b1 100644 --- a/main.go +++ b/main.go @@ -83,6 +83,7 @@ func setupEcho() *echo.Echo { e.POST("/sysadmin/users/:uname", ui.AmWrap(UserManagementSave)) e.GET("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhotoForm)) e.POST("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhoto)) + e.GET("/sysadmin/ipban", ui.AmWrap(IPBanList)) e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) e.POST("/create_comm", ui.AmWrap(CreateCommunity)) e.GET("/manage_comm", ui.AmWrap(ManageCommunities)) diff --git a/sysadmin.go b/sysadmin.go index a36955b..750b482 100644 --- a/sysadmin.go +++ b/sysadmin.go @@ -14,12 +14,14 @@ import ( "context" "errors" "fmt" + "reflect" "strconv" "strings" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/util" + "github.com/CloudyKit/jet/v6" log "github.com/sirupsen/logrus" ) @@ -497,3 +499,41 @@ func AdminUserPhoto(ctxt ui.AmContext) (string, any) { } return "error", EBUTTON } + +// templateIPtoString converts an IP address in terms of "low" and "high" 64-bit values to a string. +func templateIPtoString(a jet.Arguments) reflect.Value { + low := a.Get(0).Convert(reflect.TypeFor[uint64]()).Interface().(uint64) + high := a.Get(1).Convert(reflect.TypeFor[uint64]()).Interface().(uint64) + return reflect.ValueOf(database.AmIPToString(low, high)) +} + +/* IPBanList displays the IP address ban list and allows modification. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func IPBanList(ctxt ui.AmContext) (string, any) { + if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { + return "error", ENOACCESS + } + + ipbans, err := database.AmListIPBans(ctxt.Ctx()) + if err != nil { + return "error", err + } + usernames := make([]string, len(ipbans)) + for i, ipb := range ipbans { + user, err := database.AmGetUser(ctxt.Ctx(), ipb.BlockByUid) + if err != nil { + return "error", err + } + usernames[i] = user.Username + } + ctxt.VarMap().Set("ipbans", ipbans) + ctxt.VarMap().Set("usernames", usernames) + ctxt.VarMap().SetFunc("IPtoString", templateIPtoString) + ctxt.SetFrameTitle("Manage IP Address Bans") + return "framed", "manage_ipban.jet" +} diff --git a/ui/views/manage_ipban.jet b/ui/views/manage_ipban.jet new file mode 100644 index 0000000..ac1c4a5 --- /dev/null +++ b/ui/views/manage_ipban.jet @@ -0,0 +1,139 @@ +{* + * 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/. + *} +
+ +
+

Manage IP Address Bans

+
+
+ + +
+ + ← + Return to System Administration Menu + +
+ + +
+
+ đŸšĢ +
+

IP Ban Management:

+

This page allows you to manage IP address bans. Banned IP addresses cannot access the system. Click the status icon to enable or disable a ban. + Use the Remove button to permanently delete a ban entry.

+
+
+
+ + +
+
+ + + + + + + + + + + + + + {{ if len(ipbans) == 0 }} + + + + {{ else }} + {{ range i, ipb := ipbans }} + + + + + + + + + + {{ end }} + {{ end }} + +
EnableAddressMaskExpiresAdded ByAdded OnAction
+ No IP address bans defined. +
+ {{ if ipb.Enable }} + + ✅ + + {{ else }} + + ⭕ + + {{ end }} + + {{ IPtoString(ipb.AddressLow, ipb.AddressHigh) }} + + {{ IPtoString(ipb.MaskLow, ipb.MaskHigh) }} + + {{ if isset(ipb.Expires) }} + {{ DisplayDateTime(ipb.Expires, .) }} + {{ else }} + Never + {{ end }} + + {{ usernames[i] }} + + {{ DisplayDateTime(ipb.BlockOn, .) }} + + + đŸ—‘ī¸ + Remove + +
+
+
+ + +
+ + ➕ + Add IP Ban + +
+ + +
+
+
+ â„šī¸ +
+

Status Icons:

+
+
+ ✅ + Enabled: Ban is active - this IP address is blocked from accessing the system. Click to disable. +
+
+ ⭕ + Disabled: Ban is inactive - this IP address can access the system. Click to enable. +
+
+

Note: Disabled bans appear with grayed-out text. The "Remove" button permanently deletes the ban entry from the system.

+
+
+
+
+