Ban lists: where Guard and fail2ban ban addresses
A program bans an address (every port, or a protocol's ports) for a time; the ban list says what it is kept from: the firewall, some hosts behind it, or everything. Bans drop ahead of every rule, close the address's connections, group IPv6 by /64, are kept in the database across restarts and audited. Never banned: the inside, the firewall's own addresses, the caller, the list's never. bans grant (bans:write:LIST), hc banlist/bans/ban/unban, Traffic -> Bans, a fail2ban action in contrib/, and failed logins and refused tokens logged for Guard and fail2ban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f56eefae2
commit
500bfc7bdd
44 files changed
+2541
-39
No files matched your search
@@ -532,3 +532,35 @@ type MatrixException struct {
|
||||
// Marks are the counters behind Hits ("r12", "b-ping", "f3").
|
||||
Marks []string `json:"-"`
|
||||
}
|
||||
|
||||
// BanInput asks a ban list to ban an address (POST
|
||||
// /banlists/{list}/bans).
|
||||
type BanInput struct {
|
||||
Address string `json:"address"`
|
||||
// Protocol (tcp, udp) and Ports limit the ban; with neither, every
|
||||
// port. Ports are numbers or service names, in a list or a string
|
||||
// ("22", "ssh", "http,https"); "all", "0:65535" or "1-65535" mean
|
||||
// every port. Ports without a protocol take the service's protocols,
|
||||
// or for a number tcp and udp.
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Ports json.RawMessage `json:"ports,omitempty"`
|
||||
// Time is the ban's length in seconds: 0 the list's default, -1 its
|
||||
// longest (or for ever). Longer than the list allows is cut down.
|
||||
Time int `json:"time,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// BanResult is the answer to a ban: the bans made or renewed (one per
|
||||
// protocol and port), Existing for each already in force.
|
||||
type BanResult struct {
|
||||
Bans []BanMade `json:"bans"`
|
||||
// Closed counts the banned address's connections closed.
|
||||
Closed int `json:"closed"`
|
||||
}
|
||||
|
||||
// BanMade is a ban made, or renewed (Existing).
|
||||
type BanMade struct {
|
||||
model.Ban
|
||||
Existing bool `json:"existing,omitempty"`
|
||||
}
|
||||
+20
-12
@@ -147,13 +147,14 @@ func NewSessionID() (id, csrf string) {
|
||||
const (
|
||||
ResConfig = "config" // the firewall configuration, its history and imports
|
||||
ResSets = "sets" // dynamic sets' entries (a grant may name the sets)
|
||||
ResBans = "bans" // ban lists' bans (a grant may name the lists)
|
||||
ResLog = "log" // counters, the drop log and the status
|
||||
ResAudit = "audit" // the audit log
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists them in display order.
|
||||
var Resources = []string{ResConfig, ResSets, ResLog, ResAudit}
|
||||
var Resources = []string{ResConfig, ResSets, ResBans, ResLog, ResAudit}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
@@ -163,8 +164,8 @@ const (
|
||||
|
||||
// Roles are ready-made sets of grants.
|
||||
var Roles = map[string][]model.Grant{
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResLog, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResBans, Access: Read}, {Resource: ResLog, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResBans, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
|
||||
"log": {{Resource: ResLog, Access: Read}},
|
||||
}
|
||||
|
||||
@@ -179,12 +180,13 @@ func RoleNames() []string {
|
||||
}
|
||||
|
||||
// ParseGrant reads "resource:access" ("log:read", "*:write"), and for
|
||||
// sets optionally the sets it is limited to ("sets:write:guest,portal").
|
||||
// sets and bans optionally the sets or ban lists it is limited to
|
||||
// ("sets:write:guest,portal", "bans:write:web").
|
||||
func ParseGrant(s string) (model.Grant, error) {
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
|
||||
g := model.Grant{Resource: strings.ToLower(parts[0])}
|
||||
if len(parts) < 2 {
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, or sets:write:SET1,SET2", s)
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, sets:write:SET1,SET2 or bans:write:LIST", s)
|
||||
}
|
||||
g.Access = strings.ToLower(parts[1])
|
||||
if len(parts) == 3 {
|
||||
@@ -211,8 +213,8 @@ func ValidateGrant(g *model.Grant) error {
|
||||
if g.Access != Read && g.Access != Write {
|
||||
return fmt.Errorf("access must be read or write, not %q", g.Access)
|
||||
}
|
||||
if len(g.Sets) > 0 && g.Resource != ResSets {
|
||||
return fmt.Errorf("only a sets grant can name sets (sets:write:%s)", strings.Join(g.Sets, ","))
|
||||
if len(g.Sets) > 0 && g.Resource != ResSets && g.Resource != ResBans {
|
||||
return fmt.Errorf("only a sets or bans grant can name sets or ban lists (sets:write:%s)", strings.Join(g.Sets, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -272,20 +274,26 @@ func covers(gs []model.Grant, resource string, write bool) bool {
|
||||
|
||||
// CanSet reports whether the principal may read or change the entries of
|
||||
// a dynamic set: a sets grant covers it if it names no sets, or names it.
|
||||
func (p *Principal) CanSet(name string, write bool) bool {
|
||||
func (p *Principal) CanSet(name string, write bool) bool { return p.canNamed(ResSets, name, write) }
|
||||
|
||||
// CanBan reports whether the principal may read or change the bans of a
|
||||
// ban list: a bans grant covers it if it names no lists, or names it.
|
||||
func (p *Principal) CanBan(list string, write bool) bool { return p.canNamed(ResBans, list, write) }
|
||||
|
||||
func (p *Principal) canNamed(resource, name string, write bool) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
allowed := p.User.Admin || coversSet(p.User.Grants, name, write)
|
||||
allowed := p.User.Admin || coversNamed(p.User.Grants, resource, name, write)
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
allowed = allowed && coversSet(p.Token.Grants, name, write)
|
||||
allowed = allowed && coversNamed(p.Token.Grants, resource, name, write)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func coversSet(gs []model.Grant, name string, write bool) bool {
|
||||
func coversNamed(gs []model.Grant, resource, name string, write bool) bool {
|
||||
for _, g := range gs {
|
||||
if (g.Resource != ResAll && g.Resource != ResSets) || (write && g.Access != Write) {
|
||||
if (g.Resource != ResAll && g.Resource != resource) || (write && g.Access != Write) {
|
||||
continue
|
||||
}
|
||||
if len(g.Sets) == 0 {
|
||||
|
||||
@@ -509,6 +509,12 @@ func Normalize(st *model.State) {
|
||||
normMatch(&x.Match)
|
||||
x.ToPort = normPorts(x.ToPort)
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
b.Name = strings.TrimSpace(b.Name)
|
||||
b.Scope = strings.ToLower(strings.TrimSpace(b.Scope))
|
||||
b.Hosts = normAddrs(b.Hosts)
|
||||
b.Never = normAddrs(b.Never)
|
||||
}
|
||||
// Positions are fractions after insertions between rules; renumber
|
||||
// only when two have come too close to put another between.
|
||||
st.Sort()
|
||||
|
||||
@@ -32,6 +32,34 @@ func (a Address) String() string {
|
||||
return a.Prefix.String()
|
||||
}
|
||||
|
||||
// Contains reports whether the entry holds an address.
|
||||
func (a Address) Contains(x netip.Addr) bool {
|
||||
x = x.Unmap()
|
||||
if a.First.IsValid() {
|
||||
return x.Is4() == a.First.Is4() && !x.Less(a.First) && !a.End.Less(x)
|
||||
}
|
||||
return a.Prefix.Contains(x)
|
||||
}
|
||||
|
||||
// Overlaps reports whether the entry and a prefix share an address.
|
||||
func (a Address) Overlaps(p netip.Prefix) bool {
|
||||
if !a.First.IsValid() {
|
||||
return a.Prefix.Overlaps(p)
|
||||
}
|
||||
if a.First.Is4() != p.Addr().Is4() {
|
||||
return false
|
||||
}
|
||||
// The prefix's first and last addresses against the range.
|
||||
first := p.Masked().Addr()
|
||||
last := first
|
||||
b := last.AsSlice()
|
||||
for i := p.Bits(); i < len(b)*8; i++ {
|
||||
b[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
last, _ = netip.AddrFromSlice(b)
|
||||
return !last.Less(a.First) && !a.End.Less(first)
|
||||
}
|
||||
|
||||
// ParseAddress reads an address, a prefix (host bits must be zero) or a
|
||||
// range, IPv4 or IPv6.
|
||||
func ParseAddress(s string) (Address, error) {
|
||||
|
||||
@@ -7,9 +7,10 @@ import (
|
||||
|
||||
// Grant gives access to one resource (see internal/auth).
|
||||
type Grant struct {
|
||||
Resource string `json:"resource"` // config, sets, log, audit, *
|
||||
Resource string `json:"resource"` // config, sets, bans, log, audit, *
|
||||
Access string `json:"access"` // read or write (write implies read)
|
||||
// Sets limits a "sets" grant to these dynamic sets (none: all).
|
||||
// Sets limits a "sets" grant to these dynamic sets, and a "bans"
|
||||
// grant to these ban lists (none: all).
|
||||
Sets []string `json:"sets,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ban is an address a ban list keeps out (docs/bans.md): of every port,
|
||||
// or of one protocol and port. Bans are kept apart from the
|
||||
// configuration, like a dynamic set's entries: not in the history, and
|
||||
// not on probation.
|
||||
type Ban struct {
|
||||
ID int64 `json:"id"`
|
||||
ListID int64 `json:"list_id"`
|
||||
List string `json:"list"`
|
||||
// Address is what is banned: an address, or an IPv6 prefix of the
|
||||
// list's grouping ("2001:db8:1:2::/64").
|
||||
Address string `json:"address"`
|
||||
Protocol string `json:"protocol,omitempty"` // tcp or udp; "" every port
|
||||
Port int `json:"port,omitempty"`
|
||||
// Expires is when the ban ends (nil: it doesn't); Timeout the
|
||||
// seconds it has left then.
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
// Who banned it (the user or token's name), Source the program's own
|
||||
// name for why ("guard/ssh", "fail2ban/sshd"), Reason free text (a
|
||||
// log line).
|
||||
Who string `json:"who"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Prefix is the banned address as a prefix.
|
||||
func (b *Ban) Prefix() (netip.Prefix, error) {
|
||||
if p, err := netip.ParsePrefix(b.Address); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
a, err := netip.ParseAddr(b.Address)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||
}
|
||||
|
||||
// BanAddress writes a banned prefix: an address alone when it is one
|
||||
// address, else the prefix.
|
||||
func BanAddress(p netip.Prefix) string {
|
||||
if p.IsSingleIP() {
|
||||
return p.Addr().String()
|
||||
}
|
||||
return p.String()
|
||||
}
|
||||
@@ -392,3 +392,103 @@ func (r *Redirect) Label() string {
|
||||
}
|
||||
return fmt.Sprintf("redirect #%d", r.ID)
|
||||
}
|
||||
|
||||
// Ban list scopes: what a list's bans keep the banned addresses from.
|
||||
const (
|
||||
// BanScopeFirewall: the firewall itself (the input chain).
|
||||
BanScopeFirewall = "firewall"
|
||||
// BanScopeHosts: the list's hosts, behind the firewall (the forward
|
||||
// chain, where port forwards' addresses are already translated).
|
||||
BanScopeHosts = "hosts"
|
||||
// BanScopeEverything: the firewall and everything behind it.
|
||||
BanScopeEverything = "everything"
|
||||
)
|
||||
|
||||
// BanScopes lists the scopes.
|
||||
var BanScopes = []string{BanScopeFirewall, BanScopeHosts, BanScopeEverything}
|
||||
|
||||
// Ban lists' defaults.
|
||||
const (
|
||||
DefaultBanTime = 3600
|
||||
DefaultBanMaxTime = 7 * 24 * 3600
|
||||
DefaultBanEntries = 10000
|
||||
DefaultBanGroupV6 = 64
|
||||
)
|
||||
|
||||
// BanList is where a program such as Guard, or fail2ban, bans addresses
|
||||
// (docs/bans.md). The program names only the address, and perhaps a
|
||||
// protocol and ports; the list says what the address is kept from. The
|
||||
// bans themselves are kept apart from the configuration, like a dynamic
|
||||
// set's entries.
|
||||
type BanList struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
// Hosts are set names or addresses: what scope hosts protects.
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
// Never are set names or addresses the list never bans.
|
||||
Never []string `json:"never,omitempty"`
|
||||
// DefaultTime is a ban's length in seconds when none is asked for
|
||||
// (0: an hour); MaxTime the longest (0: a week; -1: no limit, so
|
||||
// bans may be permanent).
|
||||
DefaultTime int `json:"default_time,omitempty"`
|
||||
MaxTime int `json:"max_time,omitempty"`
|
||||
// MaxEntries limits the bans the list holds (0: 10000).
|
||||
MaxEntries int `json:"max_entries,omitempty"`
|
||||
// GroupV6 is the IPv6 prefix length banned for an address (0: /64,
|
||||
// what one customer usually gets); IPv4 addresses are banned alone.
|
||||
GroupV6 int `json:"group_v6,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
}
|
||||
|
||||
func (b *BanList) GetID() int64 { return b.ID }
|
||||
func (b *BanList) Label() string { return b.Name }
|
||||
|
||||
// BanTime is the default ban length in seconds.
|
||||
func (b *BanList) BanTime() int {
|
||||
if b.DefaultTime > 0 {
|
||||
return b.DefaultTime
|
||||
}
|
||||
return DefaultBanTime
|
||||
}
|
||||
|
||||
// BanMaxTime is the longest ban in seconds, 0 for no limit.
|
||||
func (b *BanList) BanMaxTime() int {
|
||||
switch {
|
||||
case b.MaxTime < 0:
|
||||
return 0
|
||||
case b.MaxTime > 0:
|
||||
return b.MaxTime
|
||||
}
|
||||
return DefaultBanMaxTime
|
||||
}
|
||||
|
||||
// Entries is the most bans the list holds.
|
||||
func (b *BanList) Entries() int {
|
||||
if b.MaxEntries > 0 {
|
||||
return b.MaxEntries
|
||||
}
|
||||
return DefaultBanEntries
|
||||
}
|
||||
|
||||
// V6Group is the IPv6 prefix length an address is banned with.
|
||||
func (b *BanList) V6Group() int {
|
||||
if b.GroupV6 > 0 {
|
||||
return b.GroupV6
|
||||
}
|
||||
return DefaultBanGroupV6
|
||||
}
|
||||
|
||||
// Covers reports whether the list's bans apply to a chain.
|
||||
func (b *BanList) Covers(chain string) bool {
|
||||
switch b.Scope {
|
||||
case BanScopeFirewall:
|
||||
return chain == ChainInput
|
||||
case BanScopeHosts:
|
||||
return chain == ChainForward
|
||||
case BanScopeEverything:
|
||||
return chain == ChainInput || chain == ChainForward
|
||||
}
|
||||
return false
|
||||
}
|
||||
+28
-2
@@ -20,6 +20,7 @@ type State struct {
|
||||
Forwards []*PortForward `json:"forwards"`
|
||||
SNATs []*SourceNAT `json:"snats"`
|
||||
Redirects []*Redirect `json:"redirects"`
|
||||
BanLists []*BanList `json:"ban_lists"`
|
||||
}
|
||||
|
||||
// Object kinds, for changes and the history.
|
||||
@@ -32,10 +33,11 @@ const (
|
||||
KindForward = "forward"
|
||||
KindSNAT = "snat"
|
||||
KindRedirect = "redirect"
|
||||
KindBanList = "banlist"
|
||||
)
|
||||
|
||||
// Kinds lists the object kinds (not settings) in display order.
|
||||
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
|
||||
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect, KindBanList}
|
||||
|
||||
// Object is anything in the State with an ID.
|
||||
type Object interface {
|
||||
@@ -73,6 +75,9 @@ func (st *State) Fill() {
|
||||
if st.Redirects == nil {
|
||||
st.Redirects = []*Redirect{}
|
||||
}
|
||||
if st.BanLists == nil {
|
||||
st.BanLists = []*BanList{}
|
||||
}
|
||||
}
|
||||
|
||||
// Objects returns the objects of a kind.
|
||||
@@ -107,6 +112,10 @@ func (st *State) Objects(kind string) []Object {
|
||||
for _, o := range st.Redirects {
|
||||
out = append(out, o)
|
||||
}
|
||||
case KindBanList:
|
||||
for _, o := range st.BanLists {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -141,6 +150,8 @@ func (st *State) Put(kind string, id int64, data json.RawMessage) error {
|
||||
return put(&st.SNATs, id, data)
|
||||
case KindRedirect:
|
||||
return put(&st.Redirects, id, data)
|
||||
case KindBanList:
|
||||
return put(&st.BanLists, id, data)
|
||||
}
|
||||
return &unknownKind{kind}
|
||||
}
|
||||
@@ -187,6 +198,7 @@ func (st *State) Sort() {
|
||||
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
|
||||
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
|
||||
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
|
||||
sort.SliceStable(st.BanLists, func(i, j int) bool { return st.BanLists[i].Name < st.BanLists[j].Name })
|
||||
sort.SliceStable(st.Rules, func(i, j int) bool {
|
||||
a, b := st.Rules[i], st.Rules[j]
|
||||
if a.Chain != b.Chain {
|
||||
@@ -262,7 +274,7 @@ func (st *State) ByID(kind string, id int64) Object {
|
||||
}
|
||||
|
||||
// Find finds an object of a kind by ID or name (zones, sets, services,
|
||||
// port forwards) or by ID alone (rules, source NAT, redirects).
|
||||
// port forwards, ban lists) or by ID alone (rules, source NAT, redirects).
|
||||
func (st *State) Find(kind, ref string) Object {
|
||||
for _, o := range st.Objects(kind) {
|
||||
if itoa(o.GetID()) == ref {
|
||||
@@ -290,6 +302,20 @@ func (st *State) Find(kind, ref string) Object {
|
||||
return f
|
||||
}
|
||||
}
|
||||
case KindBanList:
|
||||
if b := st.BanList(ref); b != nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanList finds a ban list by name.
|
||||
func (st *State) BanList(name string) *BanList {
|
||||
for _, b := range st.BanLists {
|
||||
if strings.EqualFold(b.Name, name) {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+48
-1
@@ -190,6 +190,30 @@ func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force: added, or if it is there already, given
|
||||
// its new time (nftables keeps an element's old timeout when it is added
|
||||
// again, so it is added, deleted and added in one transaction).
|
||||
func (r *Runner) AddBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
t := ""
|
||||
if b.Timeout > 0 {
|
||||
t = fmt.Sprintf(" timeout %ds", b.Timeout)
|
||||
}
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\nadd element inet %[1]s %[2]s { %[3]s%[4]s }\n", render.Table, set, e, t)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteBan lifts a ban. One already gone (it ran out) isn't an error.
|
||||
func (r *Runner) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
set := render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")
|
||||
e := render.BanElement(b)
|
||||
script := fmt.Sprintf("add element inet %[1]s %[2]s { %[3]s }\ndelete element inet %[1]s %[2]s { %[3]s }\n", render.Table, set, e)
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- reading JSON
|
||||
|
||||
// Table is what Housecarl reads back from its table: counters by mark
|
||||
@@ -367,7 +391,7 @@ func ParseTable(data []byte) (*Table, error) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") || render.IsBanSet(s.Name) {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
@@ -428,6 +452,9 @@ type Firewall struct {
|
||||
// quotaRules are the rules with a quota in the ruleset in force, by
|
||||
// ID: what their usage was counted by (nil before the first apply).
|
||||
quotaRules map[int64]*model.Rule
|
||||
// Bans returns the active bans, which every apply puts back (the
|
||||
// database keeps them; nil: none).
|
||||
Bans func() []render.Ban
|
||||
// flowDevs are the flowtable's devices in the ruleset in force, by
|
||||
// name, with their interface index then (0: left out, missing).
|
||||
flowDevs map[string]int
|
||||
@@ -654,6 +681,9 @@ func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
usage.apply(f.adjust)
|
||||
f.adjust = nil
|
||||
opt.Quotas, opt.QuotaSets = usage.Quotas, usage.QuotaSets
|
||||
if f.Bans != nil {
|
||||
opt.Bans = f.Bans()
|
||||
}
|
||||
if old != nil {
|
||||
opt.DeviceSets = old.DeviceSets
|
||||
}
|
||||
@@ -708,7 +738,16 @@ func pruneCounters(base map[string]Counter, st *model.State) {
|
||||
for _, x := range st.Redirects {
|
||||
ids[fmt.Sprintf("d%d", x.ID)] = true
|
||||
}
|
||||
for _, x := range st.BanLists {
|
||||
ids[render.BanMark(x.ID)] = true
|
||||
}
|
||||
for mark := range base {
|
||||
if strings.HasPrefix(mark, "ban") && strings.Trim(mark[3:], "0123456789") == "" {
|
||||
if !ids[mark] {
|
||||
delete(base, mark)
|
||||
}
|
||||
continue
|
||||
}
|
||||
obj, _, _ := strings.Cut(mark, "-") // f3-hairpin, f3-6 are f3's
|
||||
if len(obj) < 2 || !strings.ContainsRune("rfsd", rune(obj[0])) || strings.Trim(obj[1:], "0123456789") != "" {
|
||||
continue // b-established, p-input
|
||||
@@ -791,6 +830,14 @@ func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int
|
||||
return f.Runner.AddElement(ctx, set, addr, timeout)
|
||||
}
|
||||
|
||||
// AddBan puts a ban in force (Runner.AddBan).
|
||||
func (f *Firewall) AddBan(ctx context.Context, b render.Ban) error { return f.Runner.AddBan(ctx, b) }
|
||||
|
||||
// DeleteBan lifts a ban (Runner.DeleteBan).
|
||||
func (f *Firewall) DeleteBan(ctx context.Context, b render.Ban) error {
|
||||
return f.Runner.DeleteBan(ctx, b)
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return f.Runner.DeleteElement(ctx, set, addr)
|
||||
|
||||
@@ -3,6 +3,7 @@ package nft
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -543,3 +544,66 @@ func TestPruneCounters(t *testing.T) {
|
||||
t.Errorf("kept %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBansInContainer: a ban goes in, a second one renews its time, a
|
||||
// reload keeps the bans the database has, and lifting one already gone
|
||||
// isn't an error.
|
||||
func TestBansInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-bans"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
var active []render.Ban
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}, Bans: func() []render.Ban { return active }}
|
||||
ctx := context.Background()
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.BanLists = []*model.BanList{{ID: 2, Name: "router", Scope: model.BanScopeFirewall}}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
list := func(set string) string {
|
||||
out, _ := exec.Command("docker", "exec", name, "nft", "list", "set", "inet", "housecarl", set).CombinedOutput()
|
||||
return string(out)
|
||||
}
|
||||
b := render.Ban{List: 2, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 60}
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Timeout = 7200
|
||||
if err := fw.AddBan(ctx, b); err != nil {
|
||||
t.Fatalf("renewing: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v4"); !strings.Contains(got, "203.0.113.9 timeout 2h") {
|
||||
t.Errorf("renewed ban: %s", got)
|
||||
}
|
||||
svc := render.Ban{List: 2, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22}
|
||||
if err := fw.AddBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active = []render.Ban{svc}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("reapply: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); !strings.Contains(got, "2001:db8:1:2:: . 6 . 22") {
|
||||
t.Errorf("after a reload, the database's ban: %s", got)
|
||||
}
|
||||
if got := list("ban_2_v4"); strings.Contains(got, "203.0.113.9") {
|
||||
t.Errorf("after a reload, a ban the database doesn't have: %s", got)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.DeleteBan(ctx, svc); err != nil {
|
||||
t.Errorf("lifting a ban already gone: %v", err)
|
||||
}
|
||||
if got := list("ban_2_v6_svc"); strings.Contains(got, "2001:db8") {
|
||||
t.Errorf("lifted ban: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Ban is an active ban, for rendering (Options.Bans): its list, the
|
||||
// address banned (an IPv4 address, or an IPv6 prefix of the list's
|
||||
// grouping), and for a ban of one port its protocol and port. Timeout is
|
||||
// the seconds it has left (0: it doesn't end).
|
||||
type Ban struct {
|
||||
List int64
|
||||
Addr netip.Prefix
|
||||
Proto string // "tcp", "udp", or "" for every port
|
||||
Port int
|
||||
Timeout int
|
||||
}
|
||||
|
||||
// BanSet is the name of a ban list's set for a family, and with svc
|
||||
// true its set of bans of one port.
|
||||
func BanSet(list int64, v6, svc bool) string {
|
||||
name := fmt.Sprintf("ban_%d_v4", list)
|
||||
if v6 {
|
||||
name = fmt.Sprintf("ban_%d_v6", list)
|
||||
}
|
||||
if svc {
|
||||
name += "_svc"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BanMark is the mark (comment "hc:ban3") of a ban list's rules.
|
||||
func BanMark(list int64) string { return fmt.Sprintf("ban%d", list) }
|
||||
|
||||
// BanElement is the set element of a ban: "203.0.113.9", or
|
||||
// "203.0.113.9 . tcp . 22".
|
||||
func BanElement(b Ban) string {
|
||||
e := b.Addr.Addr().String()
|
||||
if b.Proto != "" {
|
||||
e += fmt.Sprintf(" . %s . %d", b.Proto, b.Port)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// IsBanSet reports whether a set is a ban list's.
|
||||
func IsBanSet(name string) bool { return strings.HasPrefix(name, "ban_") }
|
||||
|
||||
// banSets declares the ban lists' sets.
|
||||
func (r *renderer) banSets() {
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := "ban list " + b.Name
|
||||
for _, v6 := range []bool{false, true} {
|
||||
r.w.emit(obj, "\tset %s { type %s; flags timeout; }", BanSet(b.ID, v6, false), addrType(v6))
|
||||
r.w.emit(obj, "\tset %s { type %s . inet_proto . inet_service; flags timeout; }", BanSet(b.ID, v6, true), addrType(v6))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// banRules writes the drops of the ban lists that cover a chain, ahead
|
||||
// of everything that accepts: a banned address's established connections
|
||||
// are cut too.
|
||||
func (r *renderer) banRules(chain string) {
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.Disabled || !b.Covers(chain) {
|
||||
continue
|
||||
}
|
||||
obj := "ban list " + b.Name
|
||||
d4, d6 := r.st.SplitRefs(b.Hosts)
|
||||
for _, fam := range []string{"v4", "v6"} {
|
||||
dsts := []string{""}
|
||||
if b.Scope == model.BanScopeHosts {
|
||||
refs := d4
|
||||
if fam == "v6" {
|
||||
refs = d6
|
||||
}
|
||||
var ok bool
|
||||
if dsts, ok = addrAlts(fam, "daddr", refs, true, false); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
key := ipKeyword(fam) + " saddr"
|
||||
if fam == "v6" && b.V6Group() < 128 {
|
||||
key += " and " + v6Mask(b.V6Group())
|
||||
}
|
||||
v6 := fam == "v6"
|
||||
for _, d := range dsts {
|
||||
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " @" + BanSet(b.ID, v6, false)}), " "))
|
||||
r.banDrop(obj, b.ID, strings.Join(nonEmpty([]string{d, key + " . meta l4proto . th dport @" + BanSet(b.ID, v6, true)}), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// banDrop writes one drop of a ban list, or in shadow mode counts it.
|
||||
func (r *renderer) banDrop(obj string, list int64, match string) {
|
||||
if r.opt.Shadow {
|
||||
r.w.emit(obj, "\t\t%s counter accept comment \"hc:%s %s\"", match, BanMark(list), Would("drop"))
|
||||
return
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter drop comment \"hc:%s\"", match, BanMark(list))
|
||||
}
|
||||
|
||||
// v6Mask is the mask of an IPv6 prefix length: 64 → ffff:ffff:ffff:ffff::.
|
||||
func v6Mask(bits int) string {
|
||||
mask := make([]byte, 16)
|
||||
for i := 0; i < bits; i++ {
|
||||
mask[i/8] |= 0x80 >> (i % 8)
|
||||
}
|
||||
m, _ := netip.AddrFromSlice(mask)
|
||||
return m.String()
|
||||
}
|
||||
|
||||
// banElements adds the active bans, with the time each has left, so a
|
||||
// reload keeps them.
|
||||
func (r *renderer) banElements() {
|
||||
for _, ban := range r.opt.Bans {
|
||||
var list *model.BanList
|
||||
for _, b := range r.st.BanLists {
|
||||
if b.ID == ban.List && !b.Disabled {
|
||||
list = b
|
||||
}
|
||||
}
|
||||
if list == nil || !ban.Addr.IsValid() {
|
||||
continue
|
||||
}
|
||||
timeout := ""
|
||||
if ban.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds", ban.Timeout)
|
||||
}
|
||||
r.w.emit("ban list "+list.Name, "add element inet %s %s { %s%s }", Table, BanSet(list.ID, ban.Addr.Addr().Is6(), ban.Proto != ""), BanElement(ban), timeout)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ type Options struct {
|
||||
// DeviceSets are the per-device traffic counters' entries
|
||||
// (stats_up_v4 …), so a reload keeps them.
|
||||
DeviceSets map[string][]Element
|
||||
// Bans are the ban lists' active bans, added to their sets.
|
||||
Bans []Ban
|
||||
// Shadow renders for shadow mode: nothing is blocked, rejected,
|
||||
// translated or changed (see the package comment).
|
||||
Shadow bool
|
||||
@@ -137,6 +139,7 @@ func (r *renderer) render() {
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
r.sets()
|
||||
r.banSets()
|
||||
r.deviceSets()
|
||||
devices := r.flowtableDevices()
|
||||
if len(devices) > 0 {
|
||||
@@ -165,6 +168,7 @@ func (r *renderer) render() {
|
||||
w.line = base + chains.line
|
||||
w.emit("", "}")
|
||||
r.elements()
|
||||
r.banElements()
|
||||
}
|
||||
|
||||
// ---- sets
|
||||
@@ -382,6 +386,7 @@ func (r *renderer) input() {
|
||||
if b.Loopback {
|
||||
r.builtin("loopback", "iifname \"lo\" accept")
|
||||
}
|
||||
r.banRules(model.ChainInput)
|
||||
// Rules that count traffic see every packet: before the established
|
||||
// connections are accepted.
|
||||
r.rules(model.ChainInput, true)
|
||||
@@ -411,6 +416,7 @@ func (r *renderer) forward(flowtable bool) {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
r.banRules(model.ChainForward)
|
||||
ext := st.ExternalInterfaces()
|
||||
// Shadow mode changes no packet.
|
||||
if b.MSSClamp && len(ext) > 0 && !r.opt.Shadow {
|
||||
|
||||
@@ -3,6 +3,7 @@ package render
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@@ -95,10 +96,28 @@ func router() *model.State {
|
||||
st.Redirects = []*model.Redirect{
|
||||
{ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"},
|
||||
}
|
||||
st.BanLists = []*model.BanList{
|
||||
{ID: next(), Name: "router", Scope: model.BanScopeFirewall, Never: []string{"trusted"}},
|
||||
{ID: next(), Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57", "2a02:8013:7f3d:10::57", "dns_servers"}},
|
||||
{ID: next(), Name: "edge", Scope: model.BanScopeEverything, GroupV6: 48},
|
||||
{ID: next(), Name: "off", Scope: model.BanScopeEverything, Disabled: true},
|
||||
}
|
||||
st.Sort()
|
||||
return st
|
||||
}
|
||||
|
||||
// banOptions are active bans in each ban list, of every port and of one.
|
||||
func banOptions(st *model.State) []Ban {
|
||||
var out []Ban
|
||||
for _, b := range st.BanLists {
|
||||
out = append(out,
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("203.0.113.9/32"), Timeout: 3000},
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("2001:db8:1:2::/64"), Proto: "tcp", Port: 22},
|
||||
Ban{List: b.ID, Addr: netip.MustParsePrefix("198.51.100.7/32"), Proto: "udp", Port: 53, Timeout: 60})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quotaOptions carries quota usage, as a reload does: the guest quota's,
|
||||
// and two devices' of the per-device quota.
|
||||
func quotaOptions(st *model.State) (map[string]int64, map[string][]Element) {
|
||||
@@ -257,7 +276,7 @@ func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
q, qs := quotaOptions(st)
|
||||
// Enforcing, and in shadow mode after and before other tables.
|
||||
for _, mode := range []Options{{}, {Shadow: true}, {Shadow: true, ShadowBefore: true}} {
|
||||
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs,
|
||||
opt := Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}, Quotas: q, QuotaSets: qs, Bans: banOptions(st),
|
||||
DeviceSets: map[string][]Element{"stats_up_v4": {{Addr: "10.0.10.5", Timeout: 3000, Packets: 7, Bytes: 7000}}, "stats_down_v6": {{Addr: "2001:db8:1:2::", Packets: 1, Bytes: 100}}},
|
||||
Shadow: mode.Shadow, ShadowBefore: mode.ShadowBefore}
|
||||
nftCheck(t, cmd, fmt.Sprintf("%s (shadow %v, before %v)", name, mode.Shadow, mode.ShadowBefore), Render(st, opt).Text)
|
||||
@@ -277,3 +296,58 @@ func nftCheck(t *testing.T, cmd []string, name, text string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBans(t *testing.T) {
|
||||
st := router()
|
||||
id := map[string]int64{}
|
||||
for _, b := range st.BanLists {
|
||||
id[b.Name] = b.ID
|
||||
}
|
||||
text := Render(st, Options{Bans: banOptions(st)}).Text
|
||||
f := func(format string, args ...any) string { return fmt.Sprintf(format, args...) }
|
||||
r, w, e, off := id["router"], id["web"], id["edge"], id["off"]
|
||||
for _, want := range []string{
|
||||
f("set ban_%d_v4 { type ipv4_addr; flags timeout; }", r),
|
||||
f("set ban_%d_v6_svc { type ipv6_addr . inet_proto . inet_service; flags timeout; }", r),
|
||||
// The firewall: in input, every port and one port, /64 for IPv6.
|
||||
f(`ip saddr @ban_%d_v4 counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop comment "hc:ban%d"`, r, r),
|
||||
f(`ip6 saddr and ffff:ffff:ffff:ffff:: . meta l4proto . th dport @ban_%d_v6_svc counter drop comment "hc:ban%d"`, r, r),
|
||||
// Hosts: in forward, first, to the hosts (literals and sets).
|
||||
f(`ip daddr 10.0.10.57 ip saddr @ban_%d_v4 counter drop`, w),
|
||||
f(`ip daddr @dns_servers_v4 ip saddr . meta l4proto . th dport @ban_%d_v4_svc counter drop`, w),
|
||||
f(`ip6 daddr 2a02:8013:7f3d:10::57 ip6 saddr and ffff:ffff:ffff:ffff:: @ban_%d_v6 counter drop`, w),
|
||||
// Everything: input and forward, /48.
|
||||
f(`ip6 saddr and ffff:ffff:ffff:: @ban_%d_v6 counter drop`, e),
|
||||
// The bans, with their time.
|
||||
f("add element inet housecarl ban_%d_v4 { 203.0.113.9 timeout 3000s }", r),
|
||||
f("add element inet housecarl ban_%d_v6_svc { 2001:db8:1:2:: . tcp . 22 }", w),
|
||||
f("add element inet housecarl ban_%d_v4_svc { 198.51.100.7 . udp . 53 timeout 60s }", e),
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("no %q in\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, e)); n != 2 {
|
||||
t.Errorf("scope everything: %d rules of every port in IPv4, want 2 (input and forward)", n)
|
||||
}
|
||||
if n := strings.Count(text, f(`@ban_%d_v4 counter drop`, w)); n != 2 {
|
||||
t.Errorf("scope hosts: %d IPv4 rules of every port, want 2 (forward: the literal host and the set)", n)
|
||||
}
|
||||
// Ahead of the accepts: the first rules after loopback, and of forward.
|
||||
in, _, _ := strings.Cut(text[strings.Index(text, "chain input"):], "established")
|
||||
fw, _, _ := strings.Cut(text[strings.Index(text, "chain forward"):], "maxseg")
|
||||
for _, part := range []string{in, fw} {
|
||||
if strings.Contains(part, " accept comment \"hc:r") {
|
||||
t.Errorf("a rule accepts before the bans:\n%s", part)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, f("ban_%d_", off)) {
|
||||
t.Errorf("a disabled list is rendered")
|
||||
}
|
||||
shadow := Render(st, Options{Shadow: true, Bans: banOptions(st)}).Text
|
||||
if want := f(`ip saddr @ban_%d_v4 counter accept comment "hc:ban%d [would drop]"`, r, r); !strings.Contains(shadow, want) {
|
||||
t.Errorf("shadow: no %q", want)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,8 @@ func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session,
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("token refused", "ip", ip, "reason", err.Error())
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
@@ -246,6 +248,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
// For Guard and fail2ban: the address first, so a name can't fake it.
|
||||
s.log.Warn("login failed", "ip", ip, "user", name, "reason", reason)
|
||||
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// TestBans: a program with a token for one ban list bans addresses: of
|
||||
// a service's ports, every port, or numbers; renewed when banned again;
|
||||
// IPv6 by /64; never the inside, the firewall's own, or the list's
|
||||
// never; times cut to the list's longest; the address's connections the
|
||||
// ban drops are closed; and lifted by ID or address.
|
||||
func TestBans(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
|
||||
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
|
||||
for _, b := range []model.BanList{
|
||||
{Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"198.51.100.0/24"}, MaxTime: 3600},
|
||||
{Name: "router", Scope: model.BanScopeFirewall},
|
||||
} {
|
||||
if code := e.call(e.token, "POST", "/banlists", b, nil); code != 201 {
|
||||
t.Fatalf("ban list %s: %d", b.Name, code)
|
||||
}
|
||||
}
|
||||
fc := &fakeConns{list: []api.Connection{
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40000, Destination: "217.155.8.194", DestinationPort: 2222, DNATTo: "10.0.10.57", DNATPort: 22},
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40001, Destination: "10.0.10.58", DestinationPort: 22},
|
||||
{Family: "ipv4", Protocol: "tcp", Source: "203.0.113.9", SourcePort: 40002, Destination: "217.155.8.194", DestinationPort: 22},
|
||||
}}
|
||||
e.srv.SetConntrack(fc)
|
||||
var tok api.Token
|
||||
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "guard", Grants: []string{"bans:write:web"}}, &tok); code != 201 {
|
||||
t.Fatalf("token: %d", code)
|
||||
}
|
||||
ban := func(in api.BanInput, want int) api.BanResult {
|
||||
t.Helper()
|
||||
var out api.BanResult
|
||||
var eb api.ErrorBody
|
||||
code := e.call(tok.Secret, "POST", "/banlists/web/bans", in, &out)
|
||||
if code != want {
|
||||
e.call(tok.Secret, "POST", "/banlists/web/bans", in, &eb)
|
||||
t.Errorf("ban %+v: %d, want %d (%+v)", in, code, want, eb)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`"ssh"`), Time: 600, Source: "guard/ssh", Reason: "Failed password"}, 201)
|
||||
if len(out.Bans) != 1 || out.Bans[0].Protocol != "tcp" || out.Bans[0].Port != 22 || out.Bans[0].Existing || out.Bans[0].Timeout != 600 {
|
||||
t.Errorf("ssh ban: %+v", out)
|
||||
}
|
||||
if out.Closed != 1 || len(fc.closed) != 1 || fc.closed[0].DNATTo != "10.0.10.57" {
|
||||
t.Errorf("closed %d: %+v (only the one to the list's host)", out.Closed, fc.closed)
|
||||
}
|
||||
if _, ok := e.fw.bans[`ban_`+itoa(out.Bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; !ok {
|
||||
t.Errorf("not in force: %+v", e.fw.bans)
|
||||
}
|
||||
if again := ban(api.BanInput{Address: "203.0.113.9", Ports: []byte(`22`), Protocol: "tcp"}, 201); len(again.Bans) != 1 || !again.Bans[0].Existing {
|
||||
t.Errorf("banned again: %+v", again)
|
||||
}
|
||||
if v6 := ban(api.BanInput{Address: "2001:db8:1:2::77", Time: 99999}, 201); len(v6.Bans) != 1 || v6.Bans[0].Address != "2001:db8:1:2::/64" ||
|
||||
v6.Bans[0].Protocol != "" || v6.Bans[0].Timeout != 3600 {
|
||||
t.Errorf("IPv6, every port, cut to an hour: %+v", v6)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "::ffff:203.0.113.10", Ports: []byte(`53`)}, 201); len(n.Bans) != 2 || n.Bans[0].Address != "203.0.113.10" {
|
||||
t.Errorf("a number without a protocol is tcp and udp: %+v", n)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "203.0.113.11", Protocol: "tcp", Ports: []byte(`["http", "https"]`)}, 201); len(n.Bans) != 2 {
|
||||
t.Errorf("two services: %+v", n)
|
||||
}
|
||||
if n := ban(api.BanInput{Address: "203.0.113.12", Protocol: "tcp", Ports: []byte(`"0:65535"`)}, 201); len(n.Bans) != 1 || n.Bans[0].Protocol != "" {
|
||||
t.Errorf("fail2ban's every port: %+v", n)
|
||||
}
|
||||
ban(api.BanInput{Address: "203.0.113.13", Ports: []byte(`"bogus"`)}, 400)
|
||||
ban(api.BanInput{Address: "203.0.113.13", Protocol: "tcp"}, 400)
|
||||
ban(api.BanInput{Address: "not-an-address"}, 400)
|
||||
for _, a := range []string{"198.51.100.4", "10.0.10.99", "217.155.8.194", "127.0.0.1", "fe80::1", "192.168.5.5"} {
|
||||
var eb api.ErrorBody
|
||||
if code := e.call(tok.Secret, "POST", "/banlists/web/bans", api.BanInput{Address: a}, &eb); code != 422 || eb.Error.Code != "never_ban" {
|
||||
t.Errorf("never ban %s: %d %+v", a, code, eb)
|
||||
}
|
||||
}
|
||||
if code := e.call(tok.Secret, "POST", "/banlists/router/bans", api.BanInput{Address: "203.0.113.9"}, nil); code != 403 {
|
||||
t.Errorf("another list: %d", code)
|
||||
}
|
||||
// The program may read its list, and only its list.
|
||||
var bl model.BanList
|
||||
if code := e.call(tok.Secret, "GET", "/banlists/web", nil, &bl); code != 200 || bl.V6Group() != 64 {
|
||||
t.Errorf("reading its list: %d %+v", code, bl)
|
||||
}
|
||||
var lists []model.BanList
|
||||
if e.call(tok.Secret, "GET", "/banlists", nil, &lists); len(lists) != 1 || lists[0].Name != "web" {
|
||||
t.Errorf("lists: %+v", lists)
|
||||
}
|
||||
if code := e.call(tok.Secret, "GET", "/banlists/router", nil, nil); code != 403 {
|
||||
t.Errorf("reading another list: %d", code)
|
||||
}
|
||||
var bans []model.Ban
|
||||
e.call(tok.Secret, "GET", "/banlists/web/bans", nil, &bans)
|
||||
if len(bans) != 7 || bans[0].List != "web" || bans[0].Who != "admin" || bans[0].Source != "guard/ssh" {
|
||||
t.Errorf("bans: %d %+v", len(bans), bans)
|
||||
}
|
||||
var removed map[string]int
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=2001:db8:1:2::5", nil, &removed); code != 200 || removed["removed"] != 1 {
|
||||
t.Errorf("lift by address: %d %+v", code, removed)
|
||||
}
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans/"+itoa(bans[0].ID), nil, &removed); code != 200 || removed["removed"] != 1 {
|
||||
t.Errorf("lift by ID: %d %+v", code, removed)
|
||||
}
|
||||
if code := e.call(tok.Secret, "DELETE", "/banlists/web/bans?address=203.0.113.200", nil, nil); code != 404 {
|
||||
t.Errorf("lifting what isn't banned: %d", code)
|
||||
}
|
||||
if _, ok := e.fw.bans[`ban_`+itoa(bans[0].ListID)+`_v4_svc 203.0.113.9 . tcp . 22`]; ok {
|
||||
t.Errorf("still in force after lifting")
|
||||
}
|
||||
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "ban."})
|
||||
if len(evs) < 10 || !strings.Contains(evs[len(evs)-1].Detail, "guard/ssh: Failed password") {
|
||||
t.Errorf("audit: %d %+v", len(evs), evs[len(evs)-1])
|
||||
}
|
||||
// The list's name is what programs know it by.
|
||||
if code := e.call(e.token, "PATCH", "/banlists/web", map[string]any{"name": "web2"}, nil); code != 409 {
|
||||
t.Errorf("renaming a ban list: %d", code)
|
||||
}
|
||||
// Every apply puts the bans back; a deleted list's go.
|
||||
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 5 {
|
||||
t.Errorf("active bans: %d", len(got))
|
||||
}
|
||||
if code := e.call(e.token, "DELETE", "/banlists/web", nil, nil); code != 200 {
|
||||
t.Errorf("deleting the list: %d", code)
|
||||
}
|
||||
if got := ActiveBans(context.Background(), e.srv.store); len(got) != 0 {
|
||||
t.Errorf("a deleted list's bans: %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedLoginLogged: a failed login and a refused token are logged
|
||||
// with the address first, for Guard's and fail2ban's patterns.
|
||||
func TestFailedLoginLogged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var buf bytes.Buffer
|
||||
e.srv.log = slog.New(slog.NewTextHandler(&buf, nil))
|
||||
e.call("", "POST", "/auth/login", api.Login{Username: `x ip=8.8.8.8`, Password: "wrong"}, nil)
|
||||
e.call("housecarl_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil)
|
||||
for _, want := range []string{`msg="login failed" ip=127.0.0.1 user="x ip=8.8.8.8"`, `msg="token refused" ip=127.0.0.1 reason=`} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("no %q in\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/api"
|
||||
"housecarl/internal/auth"
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
"housecarl/internal/store"
|
||||
)
|
||||
|
||||
// Ban lists (docs/bans.md): the lists are configuration objects; their
|
||||
// bans are kept apart, like a dynamic set's entries, audited but not in
|
||||
// the history or on probation. A ban is saved first, then put in force;
|
||||
// every apply puts all of them back (ActiveBans).
|
||||
|
||||
// banList finds a ban list the principal may read or ban into.
|
||||
func (s *Server) banList(r *http.Request, p *auth.Principal, write bool) (*model.BanList, error) {
|
||||
ref := r.PathValue("ref")
|
||||
o := s.eng.State().Find(model.KindBanList, ref)
|
||||
if o == nil {
|
||||
if !p.CanBan(ref, write) {
|
||||
return nil, banForbidden(ref, write)
|
||||
}
|
||||
return nil, notFound("no ban list %s", ref)
|
||||
}
|
||||
b := o.(*model.BanList)
|
||||
if !p.CanBan(b.Name, write) {
|
||||
return nil, banForbidden(b.Name, write)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func banForbidden(list string, write bool) error {
|
||||
access := "read"
|
||||
if write {
|
||||
access = "write"
|
||||
}
|
||||
return forbidden("permission denied: this needs bans:%s for %s", access, list)
|
||||
}
|
||||
|
||||
// ActiveBans are the bans to put back with every apply (nft.Firewall's
|
||||
// Bans).
|
||||
func ActiveBans(ctx context.Context, st *store.Store) []render.Ban {
|
||||
list, err := st.ListBans(ctx, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]render.Ban, 0, len(list))
|
||||
for _, b := range list {
|
||||
if rb, ok := renderBan(b); ok {
|
||||
out = append(out, rb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderBan(b *model.Ban) (render.Ban, bool) {
|
||||
p, err := b.Prefix()
|
||||
if err != nil {
|
||||
return render.Ban{}, false
|
||||
}
|
||||
return render.Ban{List: b.ListID, Addr: p, Proto: b.Protocol, Port: b.Port, Timeout: b.Timeout}, true
|
||||
}
|
||||
|
||||
// listBans answers GET /banlists/{ref}/bans, and GET /bans: every list's
|
||||
// the principal may read.
|
||||
func (s *Server) listBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st := s.eng.State()
|
||||
var id int64
|
||||
if r.PathValue("ref") != "" {
|
||||
b, err := s.banList(r, p, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = b.ID
|
||||
}
|
||||
all, err := s.store.ListBans(r.Context(), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []*model.Ban{}
|
||||
for _, b := range all {
|
||||
o := st.ByID(model.KindBanList, b.ListID)
|
||||
if o == nil || !p.CanBan(o.Label(), false) {
|
||||
continue
|
||||
}
|
||||
b.List = o.Label()
|
||||
out = append(out, b)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addBan answers POST /banlists/{ref}/bans.
|
||||
func (s *Server) addBan(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
list, err := s.banList(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.BanInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
if list.Disabled {
|
||||
return conflict("ban list %s is disabled: enable it first", list.Name)
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.Trim(strings.TrimSpace(in.Address), "[]"))
|
||||
if err != nil {
|
||||
return badRequest("%q isn't an address", in.Address)
|
||||
}
|
||||
addr = addr.Unmap().WithZone("")
|
||||
bits := 32
|
||||
if addr.Is6() {
|
||||
bits = list.V6Group()
|
||||
}
|
||||
prefix := netip.PrefixFrom(addr, bits).Masked()
|
||||
if why := s.neverBan(r.Context(), st, list, p, addr, prefix); why != "" {
|
||||
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+model.BanAddress(prefix), "failure", why)
|
||||
return &httpError{http.StatusUnprocessableEntity, "never_ban", fmt.Sprintf("%s isn't banned: %s", model.BanAddress(prefix), why)}
|
||||
}
|
||||
ports, err := banPorts(st, in.Protocol, in.Ports)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
var expires *time.Time
|
||||
switch secs := banSeconds(list, in.Time); {
|
||||
case secs > 0:
|
||||
t := s.nowFn().Add(time.Duration(secs) * time.Second)
|
||||
expires = &t
|
||||
}
|
||||
if n, err := s.store.CountBans(r.Context(), list.ID); err != nil {
|
||||
return err
|
||||
} else if n+len(ports) > list.Entries() {
|
||||
existing := 0
|
||||
for _, pp := range ports {
|
||||
if s.banExists(r.Context(), list.ID, model.BanAddress(prefix), pp) {
|
||||
existing++
|
||||
}
|
||||
}
|
||||
if n+len(ports)-existing > list.Entries() {
|
||||
return &httpError{http.StatusUnprocessableEntity, "list_full", fmt.Sprintf("ban list %s holds its most bans (%d, max_entries); wait for some to end, or raise it", list.Name, list.Entries())}
|
||||
}
|
||||
}
|
||||
out := api.BanResult{Bans: []api.BanMade{}}
|
||||
for _, pp := range ports {
|
||||
b := &model.Ban{ListID: list.ID, List: list.Name, Address: model.BanAddress(prefix), Protocol: pp.proto, Port: pp.port,
|
||||
Expires: expires, Who: p.Name(), Source: clip(in.Source, 100), Reason: clip(in.Reason, 500)}
|
||||
existing, err := s.store.PutBan(r.Context(), b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if expires != nil {
|
||||
b.Timeout = int(time.Until(*expires).Round(time.Second) / time.Second)
|
||||
}
|
||||
rb, _ := renderBan(b)
|
||||
if err := s.fw.AddBan(r.Context(), rb); err != nil {
|
||||
s.log.Warn("putting a ban in force failed; the next change or restart adds it", "list", list.Name, "address", b.Address, "err", err)
|
||||
}
|
||||
out.Bans = append(out.Bans, api.BanMade{Ban: *b, Existing: existing})
|
||||
detail := b.Source
|
||||
if b.Reason != "" {
|
||||
detail = strings.TrimSpace(detail + ": " + b.Reason)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "ban.add", list.Name+" "+banLabel(b), "success", detail)
|
||||
}
|
||||
out.Closed = s.closeBanned(r.Context(), st, list, prefix, ports)
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) banExists(ctx context.Context, list int64, addr string, pp banPort) bool {
|
||||
bans, _ := s.store.ListBans(ctx, list)
|
||||
for _, b := range bans {
|
||||
if b.Address == addr && b.Protocol == pp.proto && b.Port == pp.port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// banSeconds is a ban's length: what was asked (0 the list's default, -1
|
||||
// its longest), cut to the list's longest. 0 means it doesn't end.
|
||||
func banSeconds(list *model.BanList, asked int) int {
|
||||
max := list.BanMaxTime()
|
||||
secs := asked
|
||||
switch {
|
||||
case asked == 0:
|
||||
secs = list.BanTime()
|
||||
case asked < 0:
|
||||
secs = max
|
||||
}
|
||||
if max > 0 && secs > max {
|
||||
secs = max
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
func banLabel(b *model.Ban) string {
|
||||
if b.Protocol == "" {
|
||||
return b.Address
|
||||
}
|
||||
return fmt.Sprintf("%s %s/%d", b.Address, b.Protocol, b.Port)
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// banPort is one protocol and port banned; proto "" is every port.
|
||||
type banPort struct {
|
||||
proto string
|
||||
port int
|
||||
}
|
||||
|
||||
// maxBanPorts is the most ports one ban request may name: a range in a
|
||||
// service counts each port.
|
||||
const maxBanPorts = 64
|
||||
|
||||
// banPorts reads a ban request's protocol and ports (api.BanInput).
|
||||
func banPorts(st *model.State, protocol string, raw json.RawMessage) ([]banPort, error) {
|
||||
protocol = strings.ToLower(strings.TrimSpace(protocol))
|
||||
if protocol != "" && protocol != "tcp" && protocol != "udp" && protocol != "all" {
|
||||
return nil, fmt.Errorf("protocol %q: use tcp, udp, or leave it out for every port", protocol)
|
||||
}
|
||||
if protocol == "all" {
|
||||
protocol = ""
|
||||
}
|
||||
var words []string
|
||||
if len(raw) > 0 && string(raw) != "null" {
|
||||
var list []json.RawMessage
|
||||
if json.Unmarshal(raw, &list) != nil {
|
||||
list = []json.RawMessage{raw}
|
||||
}
|
||||
for _, item := range list {
|
||||
var n int
|
||||
var str string
|
||||
switch {
|
||||
case json.Unmarshal(item, &n) == nil:
|
||||
words = append(words, strconv.Itoa(n))
|
||||
case json.Unmarshal(item, &str) == nil:
|
||||
words = append(words, strings.FieldsFunc(str, func(c rune) bool { return c == ',' || c == ' ' })...)
|
||||
default:
|
||||
return nil, fmt.Errorf("ports: give numbers or service names, in a list or a string")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(words) == 0 {
|
||||
if protocol != "" {
|
||||
return nil, fmt.Errorf("protocol %s without ports: give the ports, or leave the protocol out for every port", protocol)
|
||||
}
|
||||
return []banPort{{}}, nil
|
||||
}
|
||||
protos := []string{"tcp", "udp"}
|
||||
if protocol != "" {
|
||||
protos = []string{protocol}
|
||||
}
|
||||
var out []banPort
|
||||
seen := map[banPort]bool{}
|
||||
add := func(proto string, port int) {
|
||||
bp := banPort{proto, port}
|
||||
if !seen[bp] {
|
||||
seen[bp] = true
|
||||
out = append(out, bp)
|
||||
}
|
||||
}
|
||||
for _, w := range words {
|
||||
lw := strings.ToLower(w)
|
||||
switch lw {
|
||||
case "all", "any", "0:65535", "1:65535", "0-65535", "1-65535":
|
||||
return []banPort{{}}, nil
|
||||
}
|
||||
if n, err := strconv.Atoi(w); err == nil {
|
||||
if n < 1 || n > 65535 {
|
||||
return nil, fmt.Errorf("port %d: use 1 to 65535", n)
|
||||
}
|
||||
for _, p := range protos {
|
||||
add(p, n)
|
||||
}
|
||||
continue
|
||||
}
|
||||
svc := st.Service(w)
|
||||
if svc == nil {
|
||||
return nil, fmt.Errorf("%q is neither a port nor a service (built-in or yours)", w)
|
||||
}
|
||||
matched := false
|
||||
for _, e := range st.ServiceEntries([]string{svc.Name}) {
|
||||
if e.Proto != "tcp" && e.Proto != "udp" || protocol != "" && e.Proto != protocol {
|
||||
continue
|
||||
}
|
||||
for _, part := range strings.Split(e.Ports, ",") {
|
||||
lo, hi, _ := strings.Cut(strings.TrimSpace(part), "-")
|
||||
a, err1 := strconv.Atoi(lo)
|
||||
b := a
|
||||
var err2 error
|
||||
if hi != "" {
|
||||
b, err2 = strconv.Atoi(hi)
|
||||
}
|
||||
if err1 != nil || err2 != nil || b-a >= maxBanPorts {
|
||||
return nil, fmt.Errorf("service %s has the range %s: ban every port instead", svc.Name, part)
|
||||
}
|
||||
for port := a; port <= b; port++ {
|
||||
add(e.Proto, port)
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return nil, fmt.Errorf("service %s has no %s ports", svc.Name, map[bool]string{true: protocol, false: "tcp or udp"}[protocol != ""])
|
||||
}
|
||||
}
|
||||
if len(out) > maxBanPorts {
|
||||
return nil, fmt.Errorf("%d ports in one ban: ban every port instead (up to %d)", len(out), maxBanPorts)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// neverBan says why an address (banned as prefix) must not be banned, or
|
||||
// "": it is the firewall's, an inside network's, the client's own, in the
|
||||
// list's never, or not an address one bans.
|
||||
func (s *Server) neverBan(ctx context.Context, st *model.State, list *model.BanList, p *auth.Principal, addr netip.Addr, prefix netip.Prefix) string {
|
||||
switch {
|
||||
case addr.IsLoopback(), addr.IsUnspecified(), addr.IsMulticast(), addr.IsLinkLocalUnicast(), addr.IsLinkLocalMulticast():
|
||||
return "it is a loopback, link-local, multicast or unspecified address"
|
||||
}
|
||||
if own, err := netip.ParseAddr(p.IP); err == nil && prefix.Contains(own.Unmap()) {
|
||||
return "it is the address of whoever asked (" + own.Unmap().String() + ")"
|
||||
}
|
||||
inside := s.insideNetworks(st)
|
||||
if inside(addr) || inside(prefix.Addr()) {
|
||||
return "it is inside: in a network of an inside zone, or private"
|
||||
}
|
||||
external := map[string]bool{}
|
||||
for _, z := range st.Zones {
|
||||
external[z.Name] = z.External
|
||||
}
|
||||
for net, zone := range s.connected(st) {
|
||||
if !external[zone] && net.Overlaps(prefix) {
|
||||
return fmt.Sprintf("it overlaps %s, a network of zone %s", net, zone)
|
||||
}
|
||||
}
|
||||
if s.ifaces != nil {
|
||||
if all, err := s.ifaces.All(); err == nil {
|
||||
for name, ps := range all {
|
||||
for _, a := range ps {
|
||||
if prefix.Contains(a.Addr()) {
|
||||
return fmt.Sprintf("it is the firewall's own (%s on %s)", a.Addr(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range st.Settings.OwnPrefixes {
|
||||
if op, err := netip.ParsePrefix(o); err == nil && op.Overlaps(prefix) {
|
||||
return "it is in the firewall's own prefix " + o + " (settings)"
|
||||
}
|
||||
}
|
||||
for _, ref := range list.Never {
|
||||
if why := s.inNever(ctx, st, ref, prefix); why != "" {
|
||||
return why + " (the list's never)"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inNever: is prefix in a never entry (an address, or a set)?
|
||||
func (s *Server) inNever(ctx context.Context, st *model.State, ref string, prefix netip.Prefix) string {
|
||||
overlaps := func(entry string) bool {
|
||||
a, err := model.ParseAddress(entry)
|
||||
return err == nil && a.Overlaps(prefix)
|
||||
}
|
||||
if model.IsLiteral(ref) {
|
||||
if overlaps(ref) {
|
||||
return "it is in " + ref
|
||||
}
|
||||
return ""
|
||||
}
|
||||
set := st.Set(ref)
|
||||
if set == nil {
|
||||
return ""
|
||||
}
|
||||
entries := set.Entries
|
||||
if set.Dynamic {
|
||||
if t, err := s.fw.Table(ctx); err == nil {
|
||||
for _, e := range t.Elements[set.Name] {
|
||||
entries = append(entries, e.Addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, e := range entries {
|
||||
if overlaps(e) {
|
||||
return fmt.Sprintf("it is in set %s (%s)", set.Name, e)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// closeBanned closes the banned address's connections the list's bans
|
||||
// now drop: offloaded to the flowtable, they would go on without
|
||||
// reaching the rules. Not in shadow mode, which changes no traffic.
|
||||
func (s *Server) closeBanned(ctx context.Context, st *model.State, list *model.BanList, prefix netip.Prefix, ports []banPort) int {
|
||||
if s.cfg.Shadow || s.conns == nil {
|
||||
return 0
|
||||
}
|
||||
conns, err := s.conns.List(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("listing connections to close a ban's failed", "err", err)
|
||||
return 0
|
||||
}
|
||||
own := map[netip.Addr]bool{}
|
||||
if s.ifaces != nil {
|
||||
if all, err := s.ifaces.All(); err == nil {
|
||||
for _, ps := range all {
|
||||
for _, a := range ps {
|
||||
own[a.Addr()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h4, h6 := st.SplitRefs(list.Hosts)
|
||||
hosts := append(append([]string{}, h4.Literals...), h6.Literals...)
|
||||
for _, name := range append(h4.Sets, h6.Sets...) {
|
||||
if set := st.Set(name); set != nil {
|
||||
hosts = append(hosts, set.Entries...)
|
||||
}
|
||||
}
|
||||
isHost := func(a netip.Addr) bool {
|
||||
for _, h := range hosts {
|
||||
if x, err := model.ParseAddress(h); err == nil && x.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
closed := 0
|
||||
for _, c := range conns {
|
||||
src, err := netip.ParseAddr(c.Source)
|
||||
if err != nil || !prefix.Contains(src.Unmap()) {
|
||||
continue
|
||||
}
|
||||
dst, _ := netip.ParseAddr(c.Destination)
|
||||
port := c.DestinationPort
|
||||
if c.DNATTo != "" {
|
||||
dst, _ = netip.ParseAddr(c.DNATTo)
|
||||
if c.DNATPort != 0 {
|
||||
port = c.DNATPort
|
||||
}
|
||||
}
|
||||
toFirewall := own[dst.Unmap()]
|
||||
switch list.Scope {
|
||||
case model.BanScopeFirewall:
|
||||
if !toFirewall {
|
||||
continue
|
||||
}
|
||||
case model.BanScopeHosts:
|
||||
if toFirewall || !isHost(dst.Unmap()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
hit := false
|
||||
for _, pp := range ports {
|
||||
hit = hit || pp.proto == "" || pp.proto == c.Protocol && pp.port == port
|
||||
}
|
||||
if !hit {
|
||||
continue
|
||||
}
|
||||
if err := s.conns.Close(ctx, c); err == nil {
|
||||
closed++
|
||||
}
|
||||
}
|
||||
return closed
|
||||
}
|
||||
|
||||
// deleteBans answers DELETE /banlists/{ref}/bans/{id}, and DELETE
|
||||
// /banlists/{ref}/bans?address=A: every ban of that address (any of its
|
||||
// grouping), of every port and of each.
|
||||
func (s *Server) deleteBans(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
list, err := s.banList(r, p, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var gone []*model.Ban
|
||||
if idText := r.PathValue("id"); idText != "" {
|
||||
id, err := strconv.ParseInt(idText, 10, 64)
|
||||
if err != nil {
|
||||
return badRequest("ban ID %q isn't a number", idText)
|
||||
}
|
||||
b, err := s.store.GetBan(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) || err == nil && b.ListID != list.ID {
|
||||
return notFound("no ban %d in ban list %s", id, list.Name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gone = append(gone, b)
|
||||
} else {
|
||||
q := strings.Trim(strings.TrimSpace(r.URL.Query().Get("address")), "[]")
|
||||
a, err := netip.ParseAddr(q)
|
||||
if err != nil {
|
||||
return badRequest("say which ban: DELETE …/bans/ID, or …/bans?address=ADDRESS")
|
||||
}
|
||||
a = a.Unmap()
|
||||
bans, err := s.store.ListBans(r.Context(), list.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range bans {
|
||||
if pre, err := b.Prefix(); err == nil && pre.Contains(a) {
|
||||
gone = append(gone, b)
|
||||
}
|
||||
}
|
||||
if len(gone) == 0 {
|
||||
return notFound("%s isn't banned in ban list %s", a, list.Name)
|
||||
}
|
||||
}
|
||||
for _, b := range gone {
|
||||
if err := s.store.DeleteBan(r.Context(), b.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if rb, ok := renderBan(b); ok {
|
||||
if err := s.fw.DeleteBan(r.Context(), rb); err != nil {
|
||||
s.log.Warn("lifting a ban failed; the next change or restart drops it", "list", list.Name, "address", b.Address, "err", err)
|
||||
}
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "ban.remove", list.Name+" "+banLabel(b), "success", "")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"removed": len(gone)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneBans forgets the bans that ended, and those of lists st no longer
|
||||
// has.
|
||||
func (s *Server) pruneBans(ctx context.Context, st *model.State) {
|
||||
var ids []int64
|
||||
for _, b := range st.BanLists {
|
||||
ids = append(ids, b.ID)
|
||||
}
|
||||
if _, err := s.store.PruneBans(ctx, ids); err != nil {
|
||||
s.log.Warn("pruning bans failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ func (s *Server) rulesetDiff(before, after *model.State) string {
|
||||
var kindOf = map[string]string{
|
||||
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
|
||||
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
|
||||
"banlists": model.KindBanList,
|
||||
}
|
||||
|
||||
func pathKind(r *http.Request) (string, error) {
|
||||
@@ -128,13 +129,16 @@ func findObject(st *model.State, kind, ref string) (model.Object, error) {
|
||||
// ---- objects
|
||||
|
||||
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
||||
return s.listOwnBanLists(w, p)
|
||||
}
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
cur, st, draft, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -180,13 +184,22 @@ func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
}
|
||||
|
||||
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
kind, err := pathKind(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A program that bans into a list may read it (its grouping, times).
|
||||
if kind == model.KindBanList && !p.Can(auth.ResConfig, false) {
|
||||
b, err := s.banList(r, p, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
return nil
|
||||
}
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
_, st, _, err := s.viewState(w, r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -223,6 +236,8 @@ func decodeObject(kind string, data []byte) (model.Object, error) {
|
||||
o = &model.SourceNAT{}
|
||||
case model.KindRedirect:
|
||||
o = &model.Redirect{}
|
||||
case model.KindBanList:
|
||||
o = &model.BanList{}
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(data)))
|
||||
dec.DisallowUnknownFields()
|
||||
@@ -274,6 +289,8 @@ func setID(o model.Object, id int64) {
|
||||
x.ID = id
|
||||
case *model.Redirect:
|
||||
x.ID = id
|
||||
case *model.BanList:
|
||||
x.ID = id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,6 +408,11 @@ func usedBy(st *model.State, kind string, o model.Object) string {
|
||||
return f.Label()
|
||||
}
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
if has(b.Hosts) || has(b.Never) {
|
||||
return "ban list " + b.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -433,6 +455,10 @@ func renameRefs(st *model.State, kind, old, now string) {
|
||||
fix(f.PublicAddresses)
|
||||
fix(f.HairpinSources)
|
||||
}
|
||||
for _, b := range st.BanLists {
|
||||
fix(b.Hosts)
|
||||
fix(b.Never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,3 +695,19 @@ func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth
|
||||
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
|
||||
return nil
|
||||
}
|
||||
|
||||
// listOwnBanLists lists the ban lists a principal without config:read
|
||||
// may use.
|
||||
func (s *Server) listOwnBanLists(w http.ResponseWriter, p *auth.Principal) error {
|
||||
out := []*model.BanList{}
|
||||
for _, b := range s.eng.State().BanLists {
|
||||
if p.CanBan(b.Name, false) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && !p.Can(auth.ResBans, false) {
|
||||
return require(p, auth.ResConfig, false)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -104,6 +104,9 @@ func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||
return 0, err
|
||||
}
|
||||
setID(next, id)
|
||||
if old, now := o.Label(), next.Label(); old != now && op.Kind == model.KindBanList {
|
||||
return 0, conflict("ban list %s can't be renamed: the API tokens and programs (Guard, fail2ban) that ban into it go by its name; make a new list instead", old)
|
||||
}
|
||||
if old, now := o.Label(), next.Label(); old != now && isNamed(op.Kind) {
|
||||
if set, ok := o.(*model.AddressSet); ok && set.Dynamic {
|
||||
return 0, conflict("set %s is dynamic: its entries, and the API tokens and programs that fill it, go by its name; make a new set instead", old)
|
||||
|
||||
@@ -38,6 +38,11 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("GET "+p+"/sets/{ref}/elements", s.authed(s.listElements))
|
||||
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
|
||||
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
|
||||
m.HandleFunc("GET "+p+"/bans", s.authed(s.listBans))
|
||||
m.HandleFunc("GET "+p+"/banlists/{ref}/bans", s.authed(s.listBans))
|
||||
m.HandleFunc("POST "+p+"/banlists/{ref}/bans", s.authed(s.addBan))
|
||||
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans", s.authed(s.deleteBans))
|
||||
m.HandleFunc("DELETE "+p+"/banlists/{ref}/bans/{id}", s.authed(s.deleteBans))
|
||||
m.HandleFunc("GET "+p+"/quotas", s.authed(s.listQuotas))
|
||||
m.HandleFunc("GET "+p+"/connections", s.authed(s.listConnections))
|
||||
m.HandleFunc("POST "+p+"/connections/close", s.authed(s.closeConnections))
|
||||
|
||||
@@ -72,6 +72,8 @@ type Firewall interface {
|
||||
Table(ctx context.Context) (*nft.Table, error)
|
||||
AddElement(ctx context.Context, set, addr string, timeout int) error
|
||||
DeleteElement(ctx context.Context, set, addr string) error
|
||||
AddBan(ctx context.Context, b render.Ban) error
|
||||
DeleteBan(ctx context.Context, b render.Ban) error
|
||||
ListRuleset(ctx context.Context) ([]byte, error)
|
||||
DeleteTable(ctx context.Context, family, name string) error
|
||||
TableText(ctx context.Context, family, name string) (string, error)
|
||||
@@ -150,6 +152,7 @@ func New(cfg Config, eng *engine.Engine, fw Firewall, ifaces Interfaces, log *sl
|
||||
prev(st)
|
||||
}
|
||||
s.SaveQuotaUsage(context.Background())
|
||||
s.pruneBans(context.Background(), st) // the engine is locked: not s.eng.State()
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -324,6 +327,7 @@ func (s *Server) housekeeping(ctx context.Context) {
|
||||
s.log.Warn("purging sessions failed", "err", err)
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
s.pruneBans(ctx, s.eng.State())
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
|
||||
@@ -48,9 +48,25 @@ type fakeFW struct {
|
||||
ruleset string // nft -j list ruleset ("": a default)
|
||||
quotas nft.QuotaUsage
|
||||
adjusted []nft.QuotaAdjust
|
||||
bans map[string]render.Ban // by element (set and key)
|
||||
}
|
||||
|
||||
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
|
||||
func (f *fakeFW) AddBan(_ context.Context, b render.Ban) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.bans == nil {
|
||||
f.bans = map[string]render.Ban{}
|
||||
}
|
||||
f.bans[render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b)] = b
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) DeleteBan(_ context.Context, b render.Ban) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.bans, render.BanSet(b.List, b.Addr.Addr().Is6(), b.Proto != "")+" "+render.BanElement(b))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
const banCols = `id, list_id, address, protocol, port, expires_at, created_at, who, source, reason`
|
||||
|
||||
func scanBan(sc interface{ Scan(...any) error }, now time.Time) (*model.Ban, error) {
|
||||
b := &model.Ban{}
|
||||
var expires sql.NullString
|
||||
var created string
|
||||
if err := sc.Scan(&b.ID, &b.ListID, &b.Address, &b.Protocol, &b.Port, &expires, &created, &b.Who, &b.Source, &b.Reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Created = parseTS(created)
|
||||
b.Expires = parseTSPtr(expires)
|
||||
if b.Expires != nil {
|
||||
b.Timeout = max(int(b.Expires.Sub(now).Round(time.Second)/time.Second), 1)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// PutBan saves a ban, or renews the same ban (list, address, protocol
|
||||
// and port) with its new end, who and why (an empty source or reason
|
||||
// keeps the old). existing tells which; b gets
|
||||
// its ID either way.
|
||||
func (s *Store) PutBan(ctx context.Context, b *model.Ban) (existing bool, err error) {
|
||||
now := time.Now()
|
||||
err = s.db.QueryRowContext(ctx, `SELECT id FROM bans WHERE list_id = ? AND address = ? AND protocol = ? AND port = ?
|
||||
AND (expires_at IS NULL OR expires_at > ?)`, b.ListID, b.Address, b.Protocol, b.Port, ts(now)).Scan(&b.ID)
|
||||
existing = err == nil
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
b.Created = now
|
||||
err = s.db.QueryRowContext(ctx, `INSERT INTO bans (list_id, address, protocol, port, expires_at, created_at, who, source, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (list_id, address, protocol, port) DO UPDATE SET expires_at = excluded.expires_at,
|
||||
who = excluded.who, source = COALESCE(NULLIF(excluded.source, ''), bans.source),
|
||||
reason = COALESCE(NULLIF(excluded.reason, ''), bans.reason),
|
||||
created_at = CASE WHEN bans.expires_at IS NULL OR bans.expires_at > excluded.created_at THEN bans.created_at ELSE excluded.created_at END
|
||||
RETURNING id, created_at`,
|
||||
b.ListID, b.Address, b.Protocol, b.Port, tsPtr(b.Expires), ts(now), b.Who, b.Source, b.Reason).Scan(&b.ID, new(string))
|
||||
return existing, err
|
||||
}
|
||||
|
||||
// ListBans lists the active bans of a list (0: of all lists), oldest
|
||||
// first.
|
||||
func (s *Store) ListBans(ctx context.Context, listID int64) ([]*model.Ban, error) {
|
||||
now := time.Now()
|
||||
q := `SELECT ` + banCols + ` FROM bans WHERE (expires_at IS NULL OR expires_at > ?)`
|
||||
args := []any{ts(now)}
|
||||
if listID != 0 {
|
||||
q += ` AND list_id = ?`
|
||||
args = append(args, listID)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, q+` ORDER BY id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.Ban{}
|
||||
for rows.Next() {
|
||||
b, err := scanBan(rows, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetBan loads an active ban.
|
||||
func (s *Store) GetBan(ctx context.Context, id int64) (*model.Ban, error) {
|
||||
now := time.Now()
|
||||
b, err := scanBan(s.db.QueryRowContext(ctx, `SELECT `+banCols+` FROM bans WHERE id = ? AND (expires_at IS NULL OR expires_at > ?)`, id, ts(now)), now)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return b, err
|
||||
}
|
||||
|
||||
// DeleteBan removes a ban.
|
||||
func (s *Store) DeleteBan(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CountBans counts a list's active bans.
|
||||
func (s *Store) CountBans(ctx context.Context, listID int64) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM bans WHERE list_id = ? AND (expires_at IS NULL OR expires_at > ?)`, listID, ts(time.Now())).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// PruneBans deletes the bans that ended, and those of lists that no
|
||||
// longer exist (lists names the ones that do).
|
||||
func (s *Store) PruneBans(ctx context.Context, lists []int64) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE expires_at <= ?`, ts(time.Now()))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT list_id FROM bans`)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
var gone []int64
|
||||
keep := map[int64]bool{}
|
||||
for _, id := range lists {
|
||||
keep[id] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if rows.Scan(&id) == nil && !keep[id] {
|
||||
gone = append(gone, id)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, id := range gone {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM bans WHERE list_id = ?`, id)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
m, _ := res.RowsAffected()
|
||||
n += m
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -179,6 +179,23 @@ CREATE TABLE changesets (
|
||||
history_id INTEGER
|
||||
);
|
||||
CREATE INDEX changesets_status ON changesets (status);
|
||||
`,
|
||||
// 3: ban lists' bans (the lists are objects)
|
||||
`
|
||||
CREATE TABLE bans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL DEFAULT '',
|
||||
port INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
who TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
UNIQUE (list_id, address, protocol, port)
|
||||
);
|
||||
CREATE INDEX bans_expires ON bans (expires_at);
|
||||
`,
|
||||
}
|
||||
|
||||
|
||||
@@ -103,3 +103,60 @@ func TestUsersAndAudit(t *testing.T) {
|
||||
t.Errorf("audit %+v", evs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBans(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "housecarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
in := func(d time.Duration) *time.Time { t := time.Now().Add(d); return &t }
|
||||
b := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(time.Hour), Who: "guard", Source: "guard/ssh"}
|
||||
if existing, err := s.PutBan(ctx, b); err != nil || existing {
|
||||
t.Fatalf("new ban: %v %v", existing, err)
|
||||
}
|
||||
id := b.ID
|
||||
again := &model.Ban{ListID: 7, Address: "203.0.113.9", Expires: in(2 * time.Hour), Who: "guard", Reason: "again"}
|
||||
if existing, err := s.PutBan(ctx, again); err != nil || !existing || again.ID != id {
|
||||
t.Fatalf("renewed ban: %v %v id %d, want %d", existing, err, again.ID, id)
|
||||
}
|
||||
port := &model.Ban{ListID: 7, Address: "2001:db8:1:2::/64", Protocol: "tcp", Port: 22}
|
||||
if _, err := s.PutBan(ctx, port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gone := &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(-time.Second)}
|
||||
if _, err := s.PutBan(ctx, gone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := &model.Ban{ListID: 8, Address: "198.51.100.2"}
|
||||
if _, err := s.PutBan(ctx, other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := s.ListBans(ctx, 7)
|
||||
if err != nil || len(list) != 2 {
|
||||
t.Fatalf("list 7: %v %+v", err, list)
|
||||
}
|
||||
if list[0].Reason != "again" || list[0].Timeout < 7100 || list[0].Timeout > 7200 || list[1].Expires != nil || list[1].Timeout != 0 {
|
||||
t.Errorf("bans: %+v %+v", list[0], list[1])
|
||||
}
|
||||
if n, _ := s.CountBans(ctx, 7); n != 2 {
|
||||
t.Errorf("count %d", n)
|
||||
}
|
||||
// An ended ban is renewed as new.
|
||||
if existing, _ := s.PutBan(ctx, &model.Ban{ListID: 7, Address: "198.51.100.1", Expires: in(time.Minute)}); existing {
|
||||
t.Error("an ended ban counted as existing")
|
||||
}
|
||||
if n, err := s.PruneBans(ctx, []int64{7}); err != nil || n != 1 {
|
||||
t.Errorf("prune: %d %v (list 8's ban)", n, err)
|
||||
}
|
||||
if all, _ := s.ListBans(ctx, 0); len(all) != 3 {
|
||||
t.Errorf("after pruning: %+v", all)
|
||||
}
|
||||
if err := s.DeleteBan(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetBan(ctx, id); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("deleted ban: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func Check(st *model.State, env *Env) []Problem {
|
||||
for _, x := range st.Redirects {
|
||||
c.redirect(x)
|
||||
}
|
||||
c.banLists()
|
||||
return c.out
|
||||
}
|
||||
|
||||
@@ -233,9 +234,9 @@ func (c *checker) sets() {
|
||||
for _, s := range c.st.Sets {
|
||||
obj := "set " + s.Name
|
||||
c.name("set", obj, s.Name, seen)
|
||||
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_"} {
|
||||
for _, pre := range []string{"meter_", "conn_", "quota_", "stats_", "ban_"} {
|
||||
if strings.HasPrefix(strings.ToLower(s.Name), pre) {
|
||||
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics); choose another name", pre)
|
||||
c.add(Error, "set_name_reserved", obj, "set names starting with %s are Housecarl's own (rate limits, quotas, statistics, ban lists); choose another name", pre)
|
||||
}
|
||||
}
|
||||
for _, e := range s.Entries {
|
||||
@@ -612,3 +613,48 @@ func (c *checker) redirect(x *model.Redirect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) banLists() {
|
||||
seen := map[string]string{}
|
||||
for _, b := range c.st.BanLists {
|
||||
obj := "ban list " + b.Name
|
||||
c.name("banlist", obj, b.Name, seen)
|
||||
known := false
|
||||
for _, sc := range model.BanScopes {
|
||||
known = known || b.Scope == sc
|
||||
}
|
||||
if !known {
|
||||
c.add(Error, "banlist_scope", obj, "the scope is %q; use firewall (the firewall itself), hosts (the list's hosts) or everything", b.Scope)
|
||||
}
|
||||
switch {
|
||||
case b.Scope == model.BanScopeHosts && len(b.Hosts) == 0:
|
||||
c.add(Error, "banlist_hosts", obj, "a list with scope hosts needs its hosts: the addresses, or sets, of the machines it protects")
|
||||
case b.Scope != model.BanScopeHosts && len(b.Hosts) > 0:
|
||||
c.add(Error, "banlist_hosts", obj, "only a list with scope hosts has hosts; leave them out, or use scope hosts")
|
||||
}
|
||||
refs := func(what string, list []string) {
|
||||
for _, r := range list {
|
||||
if !model.IsLiteral(r) && c.st.Set(r) == nil {
|
||||
if _, err := model.ParseAddress(r); strings.ContainsAny(r, "./:") {
|
||||
c.add(Error, "banlist_address", obj, "%s: %v", what, err)
|
||||
} else {
|
||||
c.add(Error, "unknown_set", obj, "%s: there is no set %s", what, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
refs("hosts", b.Hosts)
|
||||
refs("never", b.Never)
|
||||
if b.DefaultTime < 0 || b.MaxTime < -1 {
|
||||
c.add(Error, "banlist_time", obj, "ban times are seconds: default_time 0 (an hour) or more, max_time 0 (a week), more, or -1 for no limit")
|
||||
} else if max := b.BanMaxTime(); max > 0 && b.BanTime() > max {
|
||||
c.add(Error, "banlist_time", obj, "the default ban time (%ds) is longer than the longest (%ds)", b.BanTime(), max)
|
||||
}
|
||||
if b.MaxEntries < 0 || b.MaxEntries > 1000000 {
|
||||
c.add(Error, "banlist_entries", obj, "max_entries is %d; use 1 to 1000000, or 0 for 10000", b.MaxEntries)
|
||||
}
|
||||
if b.GroupV6 != 0 && (b.GroupV6 < 16 || b.GroupV6 > 128) {
|
||||
c.add(Error, "banlist_group", obj, "group_v6 is /%d; use 16 to 128 (64 bans what one customer usually gets, 128 one address)", b.GroupV6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ func base() *model.State {
|
||||
},
|
||||
Forwards: []*model.PortForward{{ID: 7, Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", To: "10.0.10.57",
|
||||
PublicAddresses: []string{"203.0.113.7"}, Hairpin: true}},
|
||||
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
|
||||
SNATs: []*model.SourceNAT{{ID: 8, Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}}},
|
||||
BanLists: []*model.BanList{{ID: 9, Name: "web", Scope: model.BanScopeHosts, Hosts: []string{"10.0.10.57"}, Never: []string{"trusted"}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +137,17 @@ func TestCodes(t *testing.T) {
|
||||
{"redirect_service", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}}}}
|
||||
}},
|
||||
{"banlist_name", func(st *model.State) { st.BanLists[0].Name = "web list" }},
|
||||
{"banlist_scope", func(st *model.State) { st.BanLists[0].Scope = "world" }},
|
||||
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Hosts = nil }},
|
||||
{"banlist_hosts", func(st *model.State) { st.BanLists[0].Scope = model.BanScopeFirewall }},
|
||||
{"banlist_address", func(st *model.State) { st.BanLists[0].Never = []string{"10.0.0.300"} }},
|
||||
{"unknown_set", func(st *model.State) { st.BanLists[0].Hosts = []string{"webservers"} }},
|
||||
{"banlist_time", func(st *model.State) { st.BanLists[0].MaxTime = -2 }},
|
||||
{"banlist_time", func(st *model.State) { st.BanLists[0].DefaultTime, st.BanLists[0].MaxTime = 7200, 3600 }},
|
||||
{"banlist_entries", func(st *model.State) { st.BanLists[0].MaxEntries = -1 }},
|
||||
{"banlist_group", func(st *model.State) { st.BanLists[0].GroupV6 = 8 }},
|
||||
{"set_name_reserved", func(st *model.State) { st.Sets[0].Name = "ban_3" }},
|
||||
{"redirect_to_port", func(st *model.State) {
|
||||
st.Redirects = []*model.Redirect{{ID: 11, Position: 1, Match: model.Match{InZones: []string{"lan"}, Services: []string{"dns"}}, ToPort: "53-54"}}
|
||||
}},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler, setChangeHandler, loadDraft, onDraftChange, setImmediate, useDraft } from "./api.js";
|
||||
import * as changes from "./pages/changes.js";
|
||||
import * as connections from "./pages/connections.js";
|
||||
import * as bans from "./pages/bans.js";
|
||||
import * as tracePage from "./pages/trace.js";
|
||||
import * as matrixPage from "./pages/matrix.js";
|
||||
import * as statsPage from "./pages/stats.js";
|
||||
@@ -32,6 +33,7 @@ const routes = [
|
||||
{ pattern: /^services$/, page: services, nav: "services" },
|
||||
{ pattern: /^log$/, page: log, nav: "log" },
|
||||
{ pattern: /^connections$/, page: connections, nav: "connections" },
|
||||
{ pattern: /^bans$/, page: bans, nav: "bans" },
|
||||
{ pattern: /^trace$/, page: tracePage, nav: "trace" },
|
||||
{ pattern: /^stats$/, page: statsPage, nav: "stats" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
@@ -60,6 +62,7 @@ const navItems = [
|
||||
["stats", "#/stats", "Statistics", "log"],
|
||||
["log", "#/log", "Log", "log"],
|
||||
["connections", "#/connections", "Connections", "log"],
|
||||
["bans", "#/bans", "Bans", "bans"],
|
||||
["trace", "#/trace", "Trace", "log"],
|
||||
] },
|
||||
["changes", "#/changes", "Changes", "config"],
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Ban lists, and the addresses banned in them (docs/bans.md).
|
||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, toast, relTime } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { rerender, onLeave } from "../app.js";
|
||||
import { listEditor, fmtSeconds, parseSeconds, rowClass, lookupNames, named, hits, counters } from "../widgets.js";
|
||||
|
||||
const scopes = {
|
||||
firewall: "the firewall itself",
|
||||
hosts: "its hosts, behind the firewall",
|
||||
everything: "the firewall and everything behind it",
|
||||
};
|
||||
|
||||
export async function render(el) {
|
||||
const config = can("config", false);
|
||||
const write = can("config", true);
|
||||
const lists = await get("/banlists");
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const [bans, cs] = await Promise.all([get("/bans"), counters()]);
|
||||
const names = await lookupNames(bans.map((b) => b.address));
|
||||
const count = {};
|
||||
for (const b of bans) count[b.list_id] = (count[b.list_id] || 0) + 1;
|
||||
mount(out,
|
||||
h("h2", null, "Ban lists"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Name", render: (l) => h("strong", null, l.name) },
|
||||
{ label: "Keeps banned addresses from", render: (l) => [scopes[l.scope] || l.scope, l.hosts && l.hosts.length ? h("span", { class: "mono small" }, ": " + l.hosts.join(", ")) : null] },
|
||||
{ label: "Never bans", render: (l) => h("span", { class: "mono small" }, (l.never || []).join(", ") || "–") },
|
||||
{ label: "Bans", render: (l) => String(count[l.id] || 0) },
|
||||
{ label: "Dropped", render: (l) => hits(cs, "ban" + l.id) },
|
||||
{ label: "Comment", render: (l) => l.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (l) => l.pending === "deleted" ? badge("deleted", "err") : [
|
||||
h("button", { class: "small reveal", onclick: () => listDialog(l) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(l) }, "Delete")] } : null,
|
||||
].filter(Boolean), lists, { empty: "No ban lists yet.", rowClass })),
|
||||
h("h2", null, "Banned addresses"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Address", render: (b) => h("span", { class: "mono" }, named(names, b.address)) },
|
||||
{ label: "List", render: (b) => b.list },
|
||||
{ label: "Ports", render: (b) => b.protocol ? b.protocol + "/" + b.port : "all" },
|
||||
{ label: "Ends in", render: (b) => b.timeout ? fmtSeconds(b.timeout) : "never" },
|
||||
{ label: "Banned", render: (b) => relTime(b.created) },
|
||||
{ label: "By", render: (b) => [b.who, b.source ? h("span", { class: "muted small" }, " " + b.source) : null] },
|
||||
{ label: "Reason", render: (b) => h("span", { class: "small", title: b.reason || "" }, (b.reason || "").length > 80 ? b.reason.slice(0, 79) + "…" : b.reason || "") },
|
||||
{ label: "", tdClass: "actions-cell", render: (b) => mayBan() ? h("button", { class: "small reveal", onclick: () => unban(b, draw) }, "Unban") : null },
|
||||
], bans, { empty: "Nothing is banned." })));
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Bans"), h("div", { class: "muted" },
|
||||
"Programs such as Guard and fail2ban ban addresses into a ban list; the list says what a banned address is kept from. Bans are dropped ahead of every rule, established connections too, and end by themselves.")),
|
||||
h("div", { class: "actions" },
|
||||
lists.length && mayBan() ? h("button", { onclick: () => banDialog(lists, draw) }, "Ban an address…") : null,
|
||||
write ? h("button", { class: "primary", onclick: () => listDialog(null) }, "Add ban list") : null)),
|
||||
config || lists.length ? out : h("div", { class: "empty" }, "No ban lists you may see."));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (!document.hidden && !document.querySelector(".modal")) draw().catch(() => {}); }, 15000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
|
||||
// mayBan: a bans:write grant may name its lists; the server checks.
|
||||
function mayBan() { return can("bans", true); }
|
||||
|
||||
async function unban(b, draw) {
|
||||
const what = b.address + (b.protocol ? " (" + b.protocol + "/" + b.port + ")" : "");
|
||||
if (!await confirmDialog("Unban", "Lift the ban on " + what + " in " + b.list + "?", "Unban")) return;
|
||||
try { await api("DELETE", "/banlists/" + encodeURIComponent(b.list) + "/bans/" + b.id); toast("Unbanned " + what); draw(); } catch (e) { toast(e.message, "err"); }
|
||||
}
|
||||
|
||||
async function del(l) {
|
||||
if (!await confirmDialog("Delete ban list", "Delete ban list " + l.name + "? Its bans are lifted, and programs banning into it are refused.", "Delete", "danger")) return;
|
||||
try { await change("DELETE", "/banlists/" + l.id); rerender(); } catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
}
|
||||
|
||||
async function banDialog(lists, draw) {
|
||||
const list = select(lists.map((l) => [l.name, l.name + " (" + l.scope + ")"]), lists[0].name);
|
||||
const addr = input({ placeholder: "203.0.113.9 or 2001:db8::9", class: "mono" });
|
||||
const proto = select([["", "every port"], ["tcp", "TCP"], ["udp", "UDP"]], "");
|
||||
const ports = input({ placeholder: "22, ssh, http,https" });
|
||||
const t = input({ placeholder: "the list's default", class: "short" });
|
||||
const reason = input({ placeholder: "why, for the record" });
|
||||
const err = h("div");
|
||||
await modal("Ban an address", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Ban list", list), field("Address", addr, "An IPv6 address is banned with the list's grouping: its /64 unless the list says otherwise."),
|
||||
field("Ports", h("div", { class: "filter-row" }, proto, ports), "Every port, or a protocol's ports (numbers or service names)."),
|
||||
field("For", t, "1h, 1d; empty: the list's default."), field("Reason", reason)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Ban", kind: "danger", onclick: async () => {
|
||||
mount(err);
|
||||
const secs = parseSeconds(t.value);
|
||||
if (secs === null) { mount(err, h("div", { class: "msg err" }, "Write the time as 1h, 30m or 1d.")); return false; }
|
||||
if (proto.value && !ports.value.trim()) { mount(err, h("div", { class: "msg err" }, "Give the ports, or ban every port.")); return false; }
|
||||
try {
|
||||
const r = await api("POST", "/banlists/" + encodeURIComponent(list.value) + "/bans", { body: { address: addr.value.trim(), protocol: proto.value,
|
||||
ports: proto.value ? ports.value.trim() : undefined, time: secs || 0, reason: reason.value.trim(), source: "web" } });
|
||||
toast("Banned " + r.bans.map((b) => b.address + (b.protocol ? " " + b.protocol + "/" + b.port : "")).join(", ") + (r.closed ? "; " + r.closed + " connections closed" : ""));
|
||||
draw();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true });
|
||||
}
|
||||
|
||||
async function listDialog(l) {
|
||||
l = l || { scope: "firewall", hosts: [], never: [] };
|
||||
const name = input({ value: l.name || "", placeholder: "web", disabled: !!l.id });
|
||||
const scope = select(Object.entries(scopes).map(([k, v]) => [k, k + ": " + v]), l.scope);
|
||||
const hostsEd = listEditor(l.hosts || [], { placeholder: "10.0.10.57, 2001:db8:10::57, or a set", addLabel: "Add host" });
|
||||
const never = listEditor(l.never || [], { placeholder: "198.51.100.0/24, or a set", addLabel: "Add address" });
|
||||
const hostFields = field("Hosts", hostsEd, "The machines this list protects: their addresses, IPv4 and IPv6, or sets. Port forwards to them are covered too.").filter(Boolean);
|
||||
const showHosts = () => { for (const n of hostFields) n.classList.toggle("hidden", scope.value !== "hosts"); };
|
||||
scope.addEventListener("change", showHosts);
|
||||
showHosts();
|
||||
const time = input({ value: l.default_time ? fmtSeconds(l.default_time) : "", placeholder: "1h", class: "short" });
|
||||
const maxTime = input({ value: l.max_time > 0 ? fmtSeconds(l.max_time) : "", placeholder: "7d", class: "short" });
|
||||
const forever = checkbox("no limit: bans may last for ever", l.max_time < 0);
|
||||
const entries = input({ value: l.max_entries || "", placeholder: "10000", class: "short", type: "number" });
|
||||
const group = input({ value: l.group_v6 || "", placeholder: "64", class: "short", type: "number" });
|
||||
const disabled = checkbox("disabled: the list's bans aren't in force", !!l.disabled);
|
||||
const comment = input({ value: l.comment || "" });
|
||||
const err = h("div");
|
||||
await modal(l.id ? "Edit ban list " + l.name : "Add ban list", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Name", name, l.id ? "Programs and tokens know the list by its name, so it can't be changed." : "Programs ban into the list by this name; give each a token for it alone (bans:write:NAME)."),
|
||||
field("Scope", scope, "What a banned address is kept from."), ...hostFields,
|
||||
field("Never ban", never, "Besides what is never banned anyway: inside networks, the firewall's own addresses and whoever asks."),
|
||||
field("Ban time", time, "A ban's length when the program asks for none."),
|
||||
field("Longest ban", maxTime, "Longer bans are cut to this."), h("label"), forever,
|
||||
field("Most bans", entries), field("IPv6 banned by", group, "Prefix length: 64 bans what one customer usually gets, 128 one address."),
|
||||
h("label"), disabled, field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: l.id ? "Save" : "Add ban list", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const t = parseSeconds(time.value), m = parseSeconds(maxTime.value);
|
||||
if (t === null || m === null) { mount(err, h("div", { class: "msg err" }, "Write times as 1h, 30m or 7d.")); return false; }
|
||||
const body = { scope: scope.value, hosts: scope.value === "hosts" ? hostsEd.value() : [], never: never.value(),
|
||||
default_time: t || 0, max_time: forever.input.checked ? -1 : m || 0, max_entries: parseInt(entries.value, 10) || 0,
|
||||
group_v6: parseInt(group.value, 10) || 0, disabled: disabled.input.checked, comment: comment.value.trim() };
|
||||
if (!l.id) body.name = name.value.trim();
|
||||
try {
|
||||
await change(l.id ? "PATCH" : "POST", l.id ? "/banlists/" + l.id : "/banlists", body);
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) { mount(err, errorBox(e)); return false; }
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
Reference in new issue
Block a user