added initial display of IP ban editing (untested)
This commit is contained in:
+6
-1
@@ -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()
|
||||
}
|
||||
|
||||
+169
-13
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+40
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user