diff --git a/database/ipban.go b/database/ipban.go index 4f270c7..29cec8f 100644 --- a/database/ipban.go +++ b/database/ipban.go @@ -19,6 +19,8 @@ import ( "slices" "sync" "time" + + log "github.com/sirupsen/logrus" ) // IPBanEntry represents an IP address banned from the system. @@ -149,26 +151,74 @@ func setupIPBanSweep() func() { } // nukeIPBanCache completely clears the IP ban cache. -func nukeIPBanCache() { +func nukeIPBanCache(bad, good bool) { banMutex.Lock() defer banMutex.Unlock() - banSweeperReset <- true // send the reset signal to the sweeper - for k := range knownBans { - delete(knownBans, k) + if bad { + banSweeperReset <- true // send the reset signal to the sweeper + for k := range knownBans { + delete(knownBans, k) + } } - for k := range knownGood { - delete(knownGood, k) + if good { + 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 { +// 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)) - ip := net.IP(addr.FillBytes(make([]byte, 16))) + 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. @@ -191,10 +241,7 @@ func AmTestIPBan(ctx context.Context, ipAddress string) (string, error) { if addr == nil { return "", fmt.Errorf("invalid address %s", ipAddress) } - iv := big.NewInt(0) - iv.SetBytes(addr) - ivLo := big.NewInt(0).And(iv, low64mask).Uint64() - ivHi := big.NewInt(0).Rsh(iv, 64).Uint64() + 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) @@ -236,3 +283,33 @@ func AmGetIPBan(ctx context.Context, id int32) (*IPBanEntry, error) { } return &(dbdata[0]), 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 +} diff --git a/docs/MISSINGFUNCS.md b/docs/MISSINGFUNCS.md index bae7876..fab75e7 100644 --- a/docs/MISSINGFUNCS.md +++ b/docs/MISSINGFUNCS.md @@ -14,7 +14,7 @@ _(italicized items can be deferred)_ - Quote banner rotation - Sysadmin Menu: - ~~Edit Global Properties~~ - - View/Edit IP Address Bans + - ~~View/Edit IP Address Bans~~ - ~~User Account Management~~ - System Audit Logs - Import User Accounts diff --git a/main.go b/main.go index caf46b1..4a71490 100644 --- a/main.go +++ b/main.go @@ -84,6 +84,8 @@ func setupEcho() *echo.Echo { 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("/sysadmin/ipban/add", ui.AmWrap(AddIPBanForm)) + e.POST("/sysadmin/ipban/add", ui.AmWrap(AddIPBan)) 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 750b482..7e9826b 100644 --- a/sysadmin.go +++ b/sysadmin.go @@ -14,9 +14,11 @@ import ( "context" "errors" "fmt" + "net" "reflect" "strconv" "strings" + "time" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/ui" @@ -503,8 +505,9 @@ func AdminUserPhoto(ctxt ui.AmContext) (string, any) { // 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)) + high := a.Get(1).Convert(reflect.TypeFor[uint64]()).Uint() + v4 := a.Get(2).Convert(reflect.TypeFor[bool]()).Bool() + return reflect.ValueOf(database.AmIPToString(low, high, v4)) } /* IPBanList displays the IP address ban list and allows modification. @@ -519,21 +522,164 @@ func IPBanList(ctxt ui.AmContext) (string, any) { return "error", ENOACCESS } + if ctxt.HasParameter("t") { + // toggle enable status + id := ctxt.QueryParamInt("t", -1) + if id > 0 { + ipb, err := database.AmGetIPBan(ctxt.Ctx(), int32(id)) + if err == nil { + err = ipb.SetEnable(ctxt.Ctx(), !(ipb.Enable)) + } + if err != nil { + ctxt.VarMap().Set("errorMessage", err.Error()) + } + } + } else if ctxt.HasParameter("r") { + // delete entry + id := ctxt.QueryParamInt("r", -1) + if id > 0 { + ipb, err := database.AmGetIPBan(ctxt.Ctx(), int32(id)) + if err == nil { + err = ipb.Delete(ctxt.Ctx()) + } + if err != nil { + ctxt.VarMap().Set("errorMessage", err.Error()) + } + } + } + ipbans, err := database.AmListIPBans(ctxt.Ctx()) if err != nil { return "error", err } usernames := make([]string, len(ipbans)) + ipv4 := make([]bool, 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 + ipv4[i] = ipb.IsV4() } ctxt.VarMap().Set("ipbans", ipbans) ctxt.VarMap().Set("usernames", usernames) + ctxt.VarMap().Set("ipv4", ipv4) ctxt.VarMap().SetFunc("IPtoString", templateIPtoString) ctxt.SetFrameTitle("Manage IP Address Bans") return "framed", "manage_ipban.jet" } + +/* AddIPBanForm displays the form for adding a banned IP address. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func AddIPBanForm(ctxt ui.AmContext) (string, any) { + if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { + return "error", ENOACCESS + } + dlg, err := ui.AmLoadDialog("ipban") + if err != nil { + return "error", err + } + dlg.Field("mask").Value = "255.255.255.255" + dlg.Field("etime").SetInt(1) + dlg.Field("eunit").Value = "D" + return dlg.Render(ctxt) +} + +/* AddIPBan adds a new banned IP address. + * Parameters: + * ctxt - The AmContext for the request. + * Returns: + * Command string dictating what to be rendered. + * Data as a parameter for the command string. + */ +func AddIPBan(ctxt ui.AmContext) (string, any) { + if !database.AmTestPermission("Global.SysAdminAccess", ctxt.CurrentUser().BaseLevel) { + return "error", ENOACCESS + } + dlg, err := ui.AmLoadDialog("ipban") + if err != nil { + return "error", err + } + dlg.LoadFromForm(ctxt) + btn := dlg.WhichButton(ctxt) + if btn == "cancel" { + return "redirect", "/sysadmin/ipban" + } else if btn != "add" { + return "error", EBUTTON + } + err = dlg.Validate() + if err == nil { + theAddress := net.ParseIP(dlg.Field("address").Value) + isIPv4 := (theAddress.To4() != nil) + var theMask net.IP + maskStr := dlg.Field("mask").Value + if maskStr[0:1] == "/" { + maskbits, err := strconv.Atoi(maskStr[1:]) + if err != nil { + return dlg.RenderError(ctxt, fmt.Sprintf("invalid CIDR value: %v", err)) + } + if isIPv4 { + if maskbits > (net.IPv4len * 8) { + return dlg.RenderError(ctxt, fmt.Sprintf("invalid CIDR value: %v", err)) + } + maskbits += (net.IPv6len - net.IPv4len) * 8 + } else { + if maskbits > (net.IPv6len * 8) { + return dlg.RenderError(ctxt, fmt.Sprintf("invalid CIDR value: %v", err)) + } + } + tmp := net.CIDRMask(maskbits, net.IPv6len*8) + theMask = net.IP(tmp) + log.Debugf("computed mask value: %s", theMask.String()) + } else { + theMask = net.ParseIP(maskStr) + check := (theMask.To4() != nil) + if check != isIPv4 { + return dlg.RenderError(ctxt, fmt.Sprintf("inconsistent mask value: %s", maskStr)) + } + a := 0 + b := 0 + if isIPv4 { + a, b = net.IPMask(theMask.To4()).Size() + } else { + a, b = net.IPMask(theMask).Size() + } + if a == 0 && b == 0 { + return dlg.RenderError(ctxt, fmt.Sprintf("not a valid mask value: %s", maskStr)) + } + log.Debugf("parsed and vetted mask value: %s", theMask.String()) + } + + var expires *time.Time = nil + if dlg.Field("echeck").IsChecked() { + n, err := dlg.Field("etime").ValueInt() + if err != nil { + return dlg.RenderError(ctxt, fmt.Sprintf("invalid time value: %s", dlg.Field("etime").Value)) + } + v := time.Now() + switch dlg.Field("eunit").Value { + case "D": + v = v.AddDate(0, 0, n) + case "W": + v = v.AddDate(0, 0, n*7) + case "M": + v = v.AddDate(0, n, 0) + case "Y": + v = v.AddDate(n, 0, 0) + } + v = v.UTC() + expires = &v + } + err = database.AmAddIPBan(ctxt.Ctx(), theAddress, theMask, expires, dlg.Field("msg").Value, ctxt.CurrentUser()) + if err == nil { + return "redirect", "/sysadmin/ipban" + } + } + return dlg.RenderError(ctxt, err.Error()) +} diff --git a/ui/dialog.go b/ui/dialog.go index a787c27..978bb6a 100644 --- a/ui/dialog.go +++ b/ui/dialog.go @@ -14,7 +14,9 @@ import ( "embed" "fmt" "math" + "net" "net/mail" + "regexp" "strconv" "strings" "time" @@ -108,6 +110,10 @@ func AmLoadDialog(name string) (*Dialog, error) { d.Fields[i].MaxLength = d.Fields[i].Size } } + if fld.Type == "ipaddress" { + d.Fields[i].Size = 15 // max IPv4 + d.Fields[i].MaxLength = 39 // max IPv6 + } if fld.Type == "dropdown" && len(fld.Choices) == 0 { return nil, fmt.Errorf("dropdown field %s in dialog %s has no choices", fld.Name, name) } @@ -547,6 +553,35 @@ func validateEmailField(fld *DialogItem) error { return err } +/* validateIPAddressField validates an IP address field. + * Parameters: + * fld - The field to be validated. + * Returns: + * Standard Go error status. + */ +func validateIPAddressField(fld *DialogItem) error { + err := validateTextField(fld) + if err == nil { + if strings.Contains(fld.Param, "mask") { + // look for a CIDR mask value like "/24" + var ok bool + ok, err = regexp.Match("^/[0-9]+$", []byte(fld.Value)) + if err == nil { + if ok { + return nil // found it! + } + } + } + if err == nil { + ip := net.ParseIP(fld.Value) + if ip == nil { + err = fmt.Errorf("value of field \"%s\" is not a valid IP address", fld.Caption) + } + } + } + return err +} + /* validateCountryField validates a country code field. * Parameters: * fld - The field to be validated. @@ -613,6 +648,7 @@ var validators = map[string]validatorFunc{ "header": nilValidator, "hidden": nilValidator, "integer": validateIntegerField, + "ipaddress": validateIPAddressField, "localelist": nilValidator, "password": validateTextField, "rolelist": validateRoleListField, diff --git a/ui/dialogs/admin_user.yaml b/ui/dialogs/admin_user.yaml index 57f826a..27314d0 100644 --- a/ui/dialogs/admin_user.yaml +++ b/ui/dialogs/admin_user.yaml @@ -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 diff --git a/ui/dialogs/ipban.yaml b/ui/dialogs/ipban.yaml new file mode 100644 index 0000000..3198df6 --- /dev/null +++ b/ui/dialogs/ipban.yaml @@ -0,0 +1,57 @@ +# +# 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/. +# +name: "ipban.add" +formName: "ipbanform" +menuSelector: "nochange" +title: "Add IP Address Ban" +action: "/sysadmin/ipban/add" +fields: + - type: "ipaddress" + name: "address" + caption: "IP address" + required: true + - type: "ipaddress" + name: "mask" + caption: "IP address mask" + required: true + param: "mask" + - type: "checkbox" + name: "echeck" + caption: "IP address ban expires" + - type: "integer" + name: "etime" + caption: "Expires in" + param: "1-100000" + - type: "dropdown" + name: "eunit" + caption: "Expires in" + subcaption: "(units)" + required: true + choices: + - id: "D" + text: "days" + - id: "W" + text: "weeks" + - id: "M" + text: "months" + - id: "Y" + text: "years" + - type: "text" + name: "msg" + caption: "Message to display" + size: 64 + maxlength: 255 + - type: "button" + name: "add" + caption: "Add" + param: "blue" + - type: "button" + name: "cancel" + caption: "Cancel" + param: "red" diff --git a/ui/menudefs.yaml b/ui/menudefs.yaml index 11968ad..6d529ce 100644 --- a/ui/menudefs.yaml +++ b/ui/menudefs.yaml @@ -37,7 +37,7 @@ menudefs: link: "/sysadmin/globals" permission: "Global.SysAdminAccess" - text: "View/Edit IP Address Bans" - link: "/TODO/sysadmin/ip_bans" + link: "/sysadmin/ipban" permission: "Global.SysAdminAccess" - text: "View/Edit Banned Users" link: "/TODO/sysadmin/user_bans" diff --git a/ui/views/dialog.jet b/ui/views/dialog.jet index 773b04d..ad9ea7b 100644 --- a/ui/views/dialog.jet +++ b/ui/views/dialog.jet @@ -61,7 +61,7 @@
{{ CapitalizeString(errorMessage) }}.
+