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>
556 lines
16 KiB
Go
556 lines
16 KiB
Go
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)
|
|
}
|
|
}
|