- Undo is staged like any edit: in review mode the web UI's Undo, and hc history undo with -C or HOUSECARL_CHANGESET, add a revert operation that checks, when replayed, that nothing it touches changed since; applied, History links the entries. Imports and takeovers still can't be staged. - The rules page sums up a chain's built-ins in one line; a window lists them with what they do and their hits, and switches them. - Warnings about the machine (dashboard, hc status): nftables.service enabled (it loads /etc/nftables.conf at boot and flushes every table when stopped), and the imported table still in force beside Housecarl's. The docs say to disable nftables.service but not stop it. - A raw rule with other fields set is refused (raw_conflict) instead of silently ignoring them; the rule dialog's nftables section hides what it replaces, and the importer's raw rules carry only their text. - A dynamic set with configured entries is refused (set_dynamic_entries): they were silently ignored. The set dialog shows entries or the entry timeout, as fits. - The rule dialog's chain choices are just the chains' names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
9.5 KiB
Go
319 lines
9.5 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"housecarl/internal/api"
|
|
"housecarl/internal/auth"
|
|
"housecarl/internal/model"
|
|
"housecarl/internal/render"
|
|
"housecarl/internal/store"
|
|
"housecarl/internal/validate"
|
|
"housecarl/internal/version"
|
|
)
|
|
|
|
// ---- status
|
|
|
|
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLog, false) {
|
|
return forbidden("permission denied: this needs config:read or log:read")
|
|
}
|
|
st := s.eng.State()
|
|
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
|
|
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
|
|
if out.Problems == nil {
|
|
out.Problems = []validate.Problem{}
|
|
}
|
|
if p.IsAdmin() {
|
|
out.Database = s.store.Path()
|
|
}
|
|
for _, k := range model.Kinds {
|
|
out.Counts[k] = len(st.Objects(k))
|
|
}
|
|
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
|
|
out.Unconfigured = true
|
|
} else if err != nil {
|
|
out.ApplyError = err.Error()
|
|
} else {
|
|
out.Applied = true
|
|
}
|
|
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
|
|
out.OtherTables = otherTables(data)
|
|
}
|
|
out.Problems = append(out.Problems, s.hostProblems(r.Context(), out.OtherTables)...)
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
const importedTableKey = "imported_table"
|
|
|
|
// nftablesUnitLinks are where an enabled nftables.service shows (Debian
|
|
// enables it for sysinit.target).
|
|
var nftablesUnitLinks = []string{
|
|
"/etc/systemd/system/sysinit.target.wants/nftables.service",
|
|
"/etc/systemd/system/multi-user.target.wants/nftables.service",
|
|
}
|
|
|
|
// hostProblems are warnings about the machine rather than the
|
|
// configuration: another ruleset that still loads at boot, or the table
|
|
// an import came from, still in force beside Housecarl's.
|
|
func (s *Server) hostProblems(ctx context.Context, others []string) []validate.Problem {
|
|
var out []validate.Problem
|
|
for _, link := range s.unitLinks {
|
|
if _, err := os.Lstat(link); err == nil {
|
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "nftables_service", Object: "nftables.service",
|
|
Message: "nftables.service is enabled: at boot it loads /etc/nftables.conf, and stopping or restarting it flushes every table, Housecarl's included; disable it (systemctl disable nftables, without --now)"})
|
|
break
|
|
}
|
|
}
|
|
if t, err := s.store.GetMeta(ctx, importedTableKey); err == nil && t != "" {
|
|
for _, o := range others {
|
|
if o == t {
|
|
out = append(out, validate.Problem{Severity: validate.Warning, Code: "takeover_pending", Object: "table " + t,
|
|
Message: fmt.Sprintf("%s, the table you imported, still applies beside Housecarl's: a packet must get past both; once Housecarl's rules are right, delete it with 'hc takeover %q'", t, t)})
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// hostInterfaces lists the machine's interfaces with their zones.
|
|
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
|
|
out := []api.HostInterface{}
|
|
if s.ifaces == nil {
|
|
return out
|
|
}
|
|
all, err := s.ifaces.All()
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for name, ps := range all {
|
|
hi := api.HostInterface{Name: name, Addresses: []string{}, Up: s.ifaces.Up(name), Zone: zoneOf(st, name)}
|
|
for _, a := range ps {
|
|
hi.Addresses = append(hi.Addresses, a.String())
|
|
}
|
|
out = append(out, hi)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
// zoneOf names the zone an interface is in: an exact name, else the
|
|
// longest matching wildcard.
|
|
func zoneOf(st *model.State, name string) string {
|
|
best, bestLen := "", -1
|
|
for _, z := range st.Zones {
|
|
for _, i := range z.Interfaces {
|
|
if i == name {
|
|
return z.Name
|
|
}
|
|
if p, ok := strings.CutSuffix(i, "*"); ok && strings.HasPrefix(name, p) && len(p) > bestLen {
|
|
best, bestLen = z.Name, len(p)
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, false); err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, s.hostInterfaces(s.eng.State()))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) counters(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResLog, false); err != nil {
|
|
return err
|
|
}
|
|
t, err := s.fw.Table(r.Context())
|
|
if err != nil && s.eng.State().Empty() {
|
|
// Nothing applied yet: nothing counted.
|
|
writeJSON(w, http.StatusOK, api.Counters{Since: s.started, Counters: map[string]api.Counter{}})
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := api.Counters{Since: t.Since, Counters: map[string]api.Counter{}}
|
|
for k, c := range t.Counters {
|
|
out.Counters[k] = api.Counter{Packets: c.Packets, Bytes: c.Bytes}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResAudit, false); err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"),
|
|
Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, evs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
roles := map[string][]string{}
|
|
for n, gs := range auth.Roles {
|
|
roles[n] = grantStrings(gs)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"services": model.BuiltinServices(), "chains": model.Chains, "actions": model.Actions,
|
|
"roles": roles, "log_group": render.LogGroup,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ---- dynamic sets' entries
|
|
|
|
func (s *Server) dynamicSet(r *http.Request, p *auth.Principal, write bool) (*model.AddressSet, error) {
|
|
name := r.PathValue("ref")
|
|
if !p.CanSet(name, write) {
|
|
access := "read"
|
|
if write {
|
|
access = "write"
|
|
}
|
|
return nil, forbidden("permission denied: this needs sets:%s for %s", access, name)
|
|
}
|
|
set := s.eng.State().Set(name)
|
|
if set == nil {
|
|
return nil, notFound("no set %s", name)
|
|
}
|
|
if !set.Dynamic {
|
|
return nil, badRequest("set %s isn't dynamic: change its entries with PATCH /sets/%s", set.Name, set.Name)
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
func (s *Server) listElements(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
set, err := s.dynamicSet(r, p, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t, err := s.fw.Table(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.Element{}
|
|
for _, e := range t.Elements[set.Name] {
|
|
out = append(out, api.Element{Address: e.Addr, Timeout: e.Timeout})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) addElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
set, err := s.dynamicSet(r, p, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.Element
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
a, err := model.ParseAddress(in.Address)
|
|
if err != nil || !a.Prefix.IsSingleIP() {
|
|
return badRequest("a dynamic set takes single addresses (%q)", in.Address)
|
|
}
|
|
if in.Timeout < 0 || in.Timeout > 366*24*3600 {
|
|
return badRequest("the timeout is in seconds, up to a year")
|
|
}
|
|
if err := s.fw.AddElement(r.Context(), set.Name, a.String(), in.Timeout); err != nil {
|
|
return err
|
|
}
|
|
s.eng.Audit(r.Context(), p, "set.add", set.Name+" "+a.String(), "success", "")
|
|
writeJSON(w, http.StatusCreated, api.Element{Address: a.String(), Timeout: in.Timeout})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) deleteElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
set, err := s.dynamicSet(r, p, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a, err := model.ParseAddress(r.PathValue("addr"))
|
|
if err != nil {
|
|
return badRequest("%v", err)
|
|
}
|
|
if err := s.fw.DeleteElement(r.Context(), set.Name, a.String()); err != nil {
|
|
return notFound("%s isn't in set %s", a, set.Name)
|
|
}
|
|
s.eng.Audit(r.Context(), p, "set.delete", set.Name+" "+a.String(), "success", "")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
// ---- the log
|
|
|
|
// logRing keeps the latest logged packets.
|
|
type logRing struct {
|
|
mu sync.Mutex
|
|
max int
|
|
list []api.LogEntry
|
|
}
|
|
|
|
func newLogRing(max int) *logRing { return &logRing{max: max} }
|
|
|
|
// Add records a logged packet.
|
|
func (l *logRing) Add(e api.LogEntry) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.list = append(l.list, e)
|
|
if len(l.list) > l.max {
|
|
l.list = append([]api.LogEntry(nil), l.list[len(l.list)-l.max:]...)
|
|
}
|
|
}
|
|
|
|
// Latest returns up to n entries, newest first, after a time.
|
|
func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) []api.LogEntry {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
out := []api.LogEntry{}
|
|
for i := len(l.list) - 1; i >= 0 && (n <= 0 || len(out) < n); i-- {
|
|
e := l.list[i]
|
|
if !e.Time.After(after) {
|
|
break
|
|
}
|
|
if keep == nil || keep(e) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// LogPacket records a packet the firewall logged (internal/nflog calls
|
|
// it).
|
|
func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) }
|
|
|
|
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResLog, false); err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
var after time.Time
|
|
if v := q.Get("after"); v != "" {
|
|
after, _ = time.Parse(time.RFC3339Nano, v)
|
|
}
|
|
search := strings.ToLower(q.Get("q"))
|
|
writeJSON(w, http.StatusOK, s.logs.Latest(queryInt(r, "limit", 200), after, func(e api.LogEntry) bool {
|
|
if search == "" {
|
|
return true
|
|
}
|
|
return strings.Contains(strings.ToLower(e.Prefix+" "+e.Source+" "+e.Dest+" "+e.In+" "+e.Out+" "+e.Protocol), search)
|
|
}))
|
|
return nil
|
|
}
|