mirror of
https://github.com/yusing/godoxy.git
synced 2026-01-11 22:30:47 +01:00
310 lines
7.0 KiB
Go
310 lines
7.0 KiB
Go
package acl
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/puzpuzpuz/xsync/v4"
|
|
"github.com/rs/zerolog"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/yusing/godoxy/internal/common"
|
|
"github.com/yusing/godoxy/internal/logging/accesslog"
|
|
"github.com/yusing/godoxy/internal/maxmind"
|
|
"github.com/yusing/godoxy/internal/notif"
|
|
gperr "github.com/yusing/goutils/errs"
|
|
strutils "github.com/yusing/goutils/strings"
|
|
"github.com/yusing/goutils/task"
|
|
)
|
|
|
|
type Config struct {
|
|
Default string `json:"default" validate:"omitempty,oneof=allow deny"` // default: allow
|
|
AllowLocal *bool `json:"allow_local"` // default: true
|
|
Allow Matchers `json:"allow"`
|
|
Deny Matchers `json:"deny"`
|
|
Log *accesslog.ACLLoggerConfig `json:"log"`
|
|
|
|
Notify struct {
|
|
To []string `json:"to"` // list of notification providers
|
|
Interval time.Duration `json:"interval"` // interval between notifications
|
|
IncludeAllowed *bool `json:"include_allowed"` // default: false
|
|
} `json:"notify"`
|
|
|
|
config
|
|
valErr gperr.Error
|
|
}
|
|
|
|
const defaultNotifyInterval = 1 * time.Minute
|
|
|
|
type config struct {
|
|
defaultAllow bool
|
|
allowLocal bool
|
|
ipCache *xsync.Map[string, *checkCache]
|
|
|
|
// will be nil if Notify.To is empty
|
|
// these are per IP, reset every Notify.Interval
|
|
allowedCount map[string]uint32
|
|
blockedCount map[string]uint32
|
|
|
|
// these are total, never reset
|
|
totalAllowedCount uint64
|
|
totalBlockedCount uint64
|
|
|
|
logAllowed bool
|
|
// will be nil if Log is nil
|
|
logger accesslog.AccessLogger
|
|
|
|
// will never tick if Notify.To is empty
|
|
notifyTicker *time.Ticker
|
|
notifyAllowed bool
|
|
|
|
// will be nil if both Log and Notify.To are empty
|
|
logNotifyCh chan ipLog
|
|
}
|
|
|
|
type checkCache struct {
|
|
*maxmind.IPInfo
|
|
allow bool
|
|
created time.Time
|
|
}
|
|
|
|
type ipLog struct {
|
|
info *maxmind.IPInfo
|
|
allowed bool
|
|
}
|
|
|
|
// could be nil
|
|
var ActiveConfig atomic.Pointer[Config]
|
|
|
|
const cacheTTL = 1 * time.Minute
|
|
|
|
func (c *checkCache) Expired() bool {
|
|
return c.created.Add(cacheTTL).Before(time.Now())
|
|
}
|
|
|
|
// TODO: add stats
|
|
|
|
const (
|
|
ACLAllow = "allow"
|
|
ACLDeny = "deny"
|
|
)
|
|
|
|
func (c *Config) Validate() gperr.Error {
|
|
switch c.Default {
|
|
case "", ACLAllow:
|
|
c.defaultAllow = true
|
|
case ACLDeny:
|
|
c.defaultAllow = false
|
|
default:
|
|
c.valErr = gperr.New("invalid default value").Subject(c.Default)
|
|
return c.valErr
|
|
}
|
|
|
|
if c.AllowLocal != nil {
|
|
c.allowLocal = *c.AllowLocal
|
|
} else {
|
|
c.allowLocal = true
|
|
}
|
|
|
|
if c.Notify.Interval < 0 {
|
|
c.Notify.Interval = defaultNotifyInterval
|
|
}
|
|
|
|
if c.Log != nil {
|
|
c.logAllowed = c.Log.LogAllowed
|
|
}
|
|
|
|
if !c.allowLocal && !c.defaultAllow && len(c.Allow) == 0 {
|
|
c.valErr = gperr.New("allow_local is false and default is deny, but no allow rules are configured")
|
|
return c.valErr
|
|
}
|
|
|
|
c.ipCache = xsync.NewMap[string, *checkCache]()
|
|
|
|
if c.Notify.IncludeAllowed != nil {
|
|
c.notifyAllowed = *c.Notify.IncludeAllowed
|
|
} else {
|
|
c.notifyAllowed = false
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) Valid() bool {
|
|
return c != nil && c.valErr == nil
|
|
}
|
|
|
|
func (c *Config) Start(parent task.Parent) gperr.Error {
|
|
if c.Log != nil {
|
|
logger, err := accesslog.NewAccessLogger(parent, c.Log)
|
|
if err != nil {
|
|
return gperr.New("failed to start access logger").With(err)
|
|
}
|
|
c.logger = logger
|
|
}
|
|
if c.valErr != nil {
|
|
return c.valErr
|
|
}
|
|
|
|
if c.needLogOrNotify() {
|
|
c.logNotifyCh = make(chan ipLog, 100)
|
|
}
|
|
|
|
if c.needNotify() {
|
|
c.allowedCount = make(map[string]uint32)
|
|
c.blockedCount = make(map[string]uint32)
|
|
c.notifyTicker = time.NewTicker(c.Notify.Interval)
|
|
} else {
|
|
c.notifyTicker = time.NewTicker(time.Duration(math.MaxInt64)) // never tick
|
|
}
|
|
|
|
if c.needLogOrNotify() {
|
|
go c.logNotifyLoop(parent)
|
|
}
|
|
|
|
log.Info().
|
|
Str("default", c.Default).
|
|
Bool("allow_local", c.allowLocal).
|
|
Int("allow_rules", len(c.Allow)).
|
|
Int("deny_rules", len(c.Deny)).
|
|
Msg("ACL started")
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) cacheRecord(info *maxmind.IPInfo, allow bool) {
|
|
if common.ForceResolveCountry && info.City == nil {
|
|
maxmind.LookupCity(info)
|
|
}
|
|
c.ipCache.Store(info.Str, &checkCache{
|
|
IPInfo: info,
|
|
allow: allow,
|
|
created: time.Now(),
|
|
})
|
|
}
|
|
|
|
func (c *Config) needLogOrNotify() bool {
|
|
return c.needLog() || c.needNotify()
|
|
}
|
|
|
|
func (c *Config) needLog() bool {
|
|
return c.logger != nil
|
|
}
|
|
|
|
func (c *Config) needNotify() bool {
|
|
return len(c.Notify.To) > 0
|
|
}
|
|
|
|
func (c *Config) getCachedCity(ip string) string {
|
|
record, ok := c.ipCache.Load(ip)
|
|
if ok {
|
|
if record.City != nil {
|
|
if record.City.Country.IsoCode != "" {
|
|
return record.City.Country.IsoCode
|
|
}
|
|
return record.City.Location.TimeZone
|
|
}
|
|
}
|
|
return "unknown location"
|
|
}
|
|
|
|
func (c *Config) logNotifyLoop(parent task.Parent) {
|
|
defer c.notifyTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-parent.Context().Done():
|
|
return
|
|
case log := <-c.logNotifyCh:
|
|
if c.logger != nil {
|
|
if !log.allowed || c.logAllowed {
|
|
c.logger.LogACL(log.info, !log.allowed)
|
|
}
|
|
}
|
|
if c.needNotify() {
|
|
if log.allowed {
|
|
if c.notifyAllowed {
|
|
c.allowedCount[log.info.Str]++
|
|
c.totalAllowedCount++
|
|
}
|
|
} else {
|
|
c.blockedCount[log.info.Str]++
|
|
c.totalBlockedCount++
|
|
}
|
|
}
|
|
case <-c.notifyTicker.C: // will never tick when notify is disabled
|
|
total := len(c.allowedCount) + len(c.blockedCount)
|
|
if total == 0 {
|
|
continue
|
|
}
|
|
total++
|
|
fieldsBody := make(notif.ListBody, total)
|
|
i := 0
|
|
fieldsBody[i] = fmt.Sprintf("Total: allowed %d, blocked %d", c.totalAllowedCount, c.totalBlockedCount)
|
|
i++
|
|
for ip, count := range c.allowedCount {
|
|
fieldsBody[i] = fmt.Sprintf("%s (%s): allowed %d times", ip, c.getCachedCity(ip), count)
|
|
i++
|
|
}
|
|
for ip, count := range c.blockedCount {
|
|
fieldsBody[i] = fmt.Sprintf("%s (%s): blocked %d times", ip, c.getCachedCity(ip), count)
|
|
i++
|
|
}
|
|
notif.Notify(¬if.LogMessage{
|
|
Level: zerolog.InfoLevel,
|
|
Title: "ACL Summary for last " + strutils.FormatDuration(c.Notify.Interval),
|
|
Body: fieldsBody,
|
|
To: c.Notify.To,
|
|
})
|
|
clear(c.allowedCount)
|
|
clear(c.blockedCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
// log and notify if needed
|
|
func (c *Config) logAndNotify(info *maxmind.IPInfo, allowed bool) {
|
|
if c.logNotifyCh != nil {
|
|
c.logNotifyCh <- ipLog{info: info, allowed: allowed}
|
|
}
|
|
}
|
|
|
|
func (c *Config) IPAllowed(ip net.IP) bool {
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
|
|
// always allow loopback, not logged
|
|
if ip.IsLoopback() {
|
|
return true
|
|
}
|
|
|
|
if c.allowLocal && ip.IsPrivate() {
|
|
c.logAndNotify(&maxmind.IPInfo{IP: ip, Str: ip.String()}, true)
|
|
return true
|
|
}
|
|
|
|
ipStr := ip.String()
|
|
record, ok := c.ipCache.Load(ipStr)
|
|
if ok && !record.Expired() {
|
|
c.logAndNotify(record.IPInfo, record.allow)
|
|
return record.allow
|
|
}
|
|
|
|
ipAndStr := &maxmind.IPInfo{IP: ip, Str: ipStr}
|
|
if c.Allow.Match(ipAndStr) {
|
|
c.logAndNotify(ipAndStr, true)
|
|
c.cacheRecord(ipAndStr, true)
|
|
return true
|
|
}
|
|
if c.Deny.Match(ipAndStr) {
|
|
c.logAndNotify(ipAndStr, false)
|
|
c.cacheRecord(ipAndStr, false)
|
|
return false
|
|
}
|
|
|
|
c.logAndNotify(ipAndStr, c.defaultAllow)
|
|
c.cacheRecord(ipAndStr, c.defaultAllow)
|
|
return c.defaultAllow
|
|
}
|