added initial display of IP ban editing (untested)

This commit is contained in:
2026-02-18 23:22:41 -07:00
parent 95221a8f40
commit b8f9c7378c
5 changed files with 355 additions and 14 deletions
+6 -1
View File
@@ -1,6 +1,6 @@
/* /*
* Amsterdam Web Communities System * 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 * 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 * 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. // SetupDb sets up the database and associated items.
func SetupDb() (func(), error) { func SetupDb() (func(), error) {
var fn1 func() = nil var fn1 func() = nil
var fn2 func() = nil
db, err := sqlx.Open(config.GlobalConfig.Database.Driver, config.GlobalConfig.Database.Dsn) db, err := sqlx.Open(config.GlobalConfig.Database.Driver, config.GlobalConfig.Database.Dsn)
if err == nil { if err == nil {
amdb = db amdb = db
fn1 = setupAuditWriter() fn1 = setupAuditWriter()
fn2 = setupIPBanSweep()
} }
return func() { return func() {
if fn2 != nil {
fn2()
}
if fn1 != nil { if fn1 != nil {
fn1() fn1()
} }
+169 -13
View File
@@ -12,13 +12,29 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"math/big" "math/big"
"net" "net"
"slices"
"sync" "sync"
"time" "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. // low64mask is bigint 0xFFFFFFFFFFFFFFFF, used in splitting large addresses.
var low64mask *big.Int var low64mask *big.Int
@@ -40,6 +56,119 @@ func init() {
knownGood = make(map[string]bool) 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. /* AmTestIPBan tests an IP address to see if it's on the banned list.
* Parameters: * Parameters:
* ctx - Standard Go context parameter. * 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. * Ban message if the address is banned, or empty string if it isn't.
* Standard Go error status. * 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() banMutex.Lock()
defer banMutex.Unlock() defer banMutex.Unlock()
rc := knownBans[ip_address] rc := knownBans[ipAddress]
if rc != "" { if rc != "" {
return rc, nil return rc, nil
} }
if knownGood[ip_address] { if knownGood[ipAddress] {
return "", nil return "", nil
} }
addr := net.ParseIP(ip_address) addr := net.ParseIP(ipAddress)
if addr == nil { if addr == nil {
return "", fmt.Errorf("invalid address %s", ip_address) return "", fmt.Errorf("invalid address %s", ipAddress)
} }
iv := big.NewInt(0) iv := big.NewInt(0)
iv.SetBytes(addr) iv.SetBytes(addr)
iv_lo := big.NewInt(0).And(iv, low64mask).Uint64() ivLo := big.NewInt(0).And(iv, low64mask).Uint64()
iv_hi := big.NewInt(0).Rsh(iv, 64).Uint64() ivHi := big.NewInt(0).Rsh(iv, 64).Uint64()
row := amdb.QueryRowContext(ctx, `SELECT message FROM ipban WHERE (address_lo & mask_lo) = (? & mask_lo) 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 >= ?) 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`, iv_lo, iv_hi, time.Now().UTC()) AND enable <> 0 ORDER BY mask_hi DESC, mask_lo DESC`, ivLo, ivHi)
err := row.Scan(&rc) var expire *time.Time = nil
err := row.Scan(&rc, &expire)
switch err { switch err {
case nil: 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 return rc, nil
case sql.ErrNoRows: case sql.ErrNoRows:
knownGood[ip_address] = true knownGood[ipAddress] = true
return "", nil return "", nil
} }
return "", err 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
}
+1
View File
@@ -83,6 +83,7 @@ func setupEcho() *echo.Echo {
e.POST("/sysadmin/users/:uname", ui.AmWrap(UserManagementSave)) e.POST("/sysadmin/users/:uname", ui.AmWrap(UserManagementSave))
e.GET("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhotoForm)) e.GET("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhotoForm))
e.POST("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhoto)) e.POST("/sysadmin/users/:uname/photo", ui.AmWrap(AdminUserPhoto))
e.GET("/sysadmin/ipban", ui.AmWrap(IPBanList))
e.GET("/create_comm", ui.AmWrap(CreateCommunityForm)) e.GET("/create_comm", ui.AmWrap(CreateCommunityForm))
e.POST("/create_comm", ui.AmWrap(CreateCommunity)) e.POST("/create_comm", ui.AmWrap(CreateCommunity))
e.GET("/manage_comm", ui.AmWrap(ManageCommunities)) e.GET("/manage_comm", ui.AmWrap(ManageCommunities))
+40
View File
@@ -14,12 +14,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"reflect"
"strconv" "strconv"
"strings" "strings"
"git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/database"
"git.erbosoft.com/amy/amsterdam/ui" "git.erbosoft.com/amy/amsterdam/ui"
"git.erbosoft.com/amy/amsterdam/util" "git.erbosoft.com/amy/amsterdam/util"
"github.com/CloudyKit/jet/v6"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -497,3 +499,41 @@ func AdminUserPhoto(ctxt ui.AmContext) (string, any) {
} }
return "error", EBUTTON 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"
}
+139
View File
@@ -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/.
*}
<div class="p-4">
<!-- Page Title -->
<div class="mb-6">
<h1 class="text-blue-800 text-4xl font-bold mb-2">Manage IP Address Bans</h1>
<hr class="border-2 border-gray-400 w-4/5 mb-6">
</div>
<!-- Return Link -->
<div class="mb-6">
<a href="/sysadmin" class="text-blue-700 hover:text-blue-900 text-sm flex items-center gap-2 w-fit">
<span>←</span>
Return to System Administration Menu
</a>
</div>
<!-- Warning Box -->
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-6 rounded max-w-6xl">
<div class="flex items-start">
<span class="text-2xl mr-3">🚫</span>
<div class="text-sm text-red-900">
<p class="font-bold mb-1">IP Ban Management:</p>
<p>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.</p>
</div>
</div>
</div>
<!-- IP Bans Table -->
<div class="max-w-6xl mb-6">
<div class="bg-white border border-gray-300 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-100 border-b-2 border-gray-300">
<tr>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase tracking-wider w-20">Enable</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Address</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Mask</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Expires</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Added By</th>
<th class="px-4 py-3 text-left text-xs font-bold text-gray-700 uppercase tracking-wider">Added On</th>
<th class="px-4 py-3 text-center text-xs font-bold text-gray-700 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{ if len(ipbans) == 0 }}
<tr class="bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-500" colspan="7">
<i>No IP address bans defined.</i>
</td>
</tr>
{{ else }}
{{ range i, ipb := ipbans }}
<tr class="hover:bg-gray-50 {{ if !ipb.Enable }}bg-gray-50{{ end }}">
<td class="px-4 py-3 text-center">
{{ if ipb.Enable }}
<a href="/sysadmin/ipban?t={{ ipb.Id }}" class="inline-block text-3xl hover:scale-110 transition-transform"
title="Click to disable">
</a>
{{ else }}
<a href="/sysadmin/ipban?t={{ ipb.Id }}" class="inline-block text-3xl hover:scale-110 transition-transform"
title="Click to enable">
</a>
{{ end }}
</td>
<td class="px-4 py-3 text-sm font-mono {{ if ipb.Enable }}text-gray-800{{ else }}text-gray-500{{ end }}">
{{ IPtoString(ipb.AddressLow, ipb.AddressHigh) }}
</td>
<td class="px-4 py-3 text-sm font-mono {{ if ipb.Enable }}text-gray-600{{ else }}text-gray-400{{ end }}">
{{ IPtoString(ipb.MaskLow, ipb.MaskHigh) }}
</td>
<td class="px-4 py-3 text-sm {{ if ipb.Enable }}text-gray-700{{ else }}text-gray-500{{ end }} whitespace-nowrap">
{{ if isset(ipb.Expires) }}
{{ DisplayDateTime(ipb.Expires, .) }}
{{ else }}
Never
{{ end }}
</td>
<td class="px-4 py-3 text-sm {{ if ipb.Enable }}text-gray-700{{ else }}text-gray-500{{ end }}">
{{ usernames[i] }}
</td>
<td class="px-4 py-3 text-sm {{ if ipb.Enable }}text-gray-600{{ else }}text-gray-400{{ end }} whitespace-nowrap">
{{ DisplayDateTime(ipb.BlockOn, .) }}
</td>
<td class="px-4 py-3 text-center">
<a href="/sysadmin/ipban?r={{ ipb.Id }}"
class="inline-flex items-center gap-1 bg-red-600 hover:bg-red-700 text-white font-medium px-3 py-1 rounded transition-colors text-sm">
<span>🗑️</span>
Remove
</a>
</td>
</tr>
{{ end }}
{{ end }}
</tbody>
</table>
</div>
</div>
<!-- Add Button -->
<div class="max-w-6xl">
<a href="/sysadmin/ipban/add"
class="inline-flex items-center gap-2 bg-green-600 hover:bg-green-700 text-white font-bold px-6 py-3 rounded-lg transition-colors shadow-md hover:shadow-lg">
<span class="text-xl"></span>
Add IP Ban
</a>
</div>
<!-- Legend -->
<div class="max-w-6xl mt-8">
<div class="bg-blue-50 border-l-4 border-blue-400 p-4 rounded">
<div class="flex items-start">
<span class="text-2xl mr-3">️</span>
<div class="text-sm text-blue-900">
<p class="font-bold mb-2">Status Icons:</p>
<div class="space-y-1">
<div class="flex items-center gap-3">
<span class="text-2xl">✅</span>
<span><strong>Enabled:</strong> Ban is active - this IP address is blocked from accessing the system. Click to disable.</span>
</div>
<div class="flex items-center gap-3">
<span class="text-2xl">⭕</span>
<span><strong>Disabled:</strong> Ban is inactive - this IP address can access the system. Click to enable.</span>
</div>
</div>
<p class="mt-3"><strong>Note:</strong> Disabled bans appear with grayed-out text. The "Remove" button permanently deletes the ban entry from the system.</p>
</div>
</div>
</div>
</div>
</div>