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) } }