Files
abrown38andClaude Opus 5.5 42e2013cb2 Names beside inside addresses
Reverse lookups (internal/names, POST /names) put a name beside each
inside address in connections, the log, quota usage, the dashboard's
quotas and the statistics' devices and blocked sources, in the web UI
and hc (--no-names to leave them out). Only inside addresses are looked
up: an inside zone's networks, and private ones outside the external
zones'. Names are cached (names.cache, 10m) and never stored;
names.resolver picks the DNS server.

Also: hc stats top devices listed each device twice; the dashboard's
quotas named the rule's ID twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:58:35 +01:00

397 lines
10 KiB
Go

package server
import (
"context"
"fmt"
"net/http"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/trace"
)
// Traffic as it happens: the connections the kernel tracks (and closing
// them, so the rules check them again), and tracing packets through the
// whole ruleset.
// Conntrack lists and closes tracked connections (internal/conntrack).
type Conntrack interface {
List(ctx context.Context) ([]api.Connection, error)
Close(ctx context.Context, c api.Connection) error
}
// SetConntrack gives the server the kernel's connection tracking.
func (s *Server) SetConntrack(c Conntrack) { s.conns = c }
func (s *Server) conntrack() (Conntrack, error) {
if s.conns == nil {
return nil, &httpError{http.StatusServiceUnavailable, "unavailable", "connection tracking isn't available here"}
}
return s.conns, nil
}
// connMatches: a search in a connection's addresses, ports (":443"),
// protocol, state and NAT addresses.
func connMatches(c api.Connection, q string) bool {
if q == "" {
return true
}
hay := strings.ToLower(strings.Join([]string{c.Protocol, c.Source, ":" + strconv.Itoa(c.SourcePort), c.Destination, ":" + strconv.Itoa(c.DestinationPort),
c.DNATTo, c.SNATTo, c.State}, " ") + " ")
for _, w := range strings.Fields(strings.ToLower(q)) {
if strings.HasPrefix(w, ":") {
w += " " // a whole port
}
if !strings.Contains(hay, w) {
return false
}
}
return true
}
func (s *Server) listConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
ct, err := s.conntrack()
if err != nil {
return err
}
all, err := ct.List(r.Context())
if err != nil {
return fmt.Errorf("listing connections: %w", err)
}
q := r.URL.Query().Get("q")
out := []api.Connection{}
for _, c := range all {
if connMatches(c, q) {
out = append(out, c)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Source != out[j].Source {
return out[i].Source < out[j].Source
}
return out[i].Destination < out[j].Destination
})
if n := queryInt(r, "limit", 1000); n > 0 && len(out) > n {
out = out[:n]
}
w.Header().Set("X-Total-Count", strconv.Itoa(len(all)))
writeJSON(w, http.StatusOK, out)
return nil
}
// lookupNames finds the names of the inside addresses asked about
// (docs/configuration.md#names), waiting at most a second for lookups.
func (s *Server) lookupNames(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
var in api.NamesInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if len(in.Addresses) > maxNames {
return badRequest("ask for at most %d addresses at once", maxNames)
}
out := api.Names{Enabled: s.cfg.Names != nil, Names: map[string]string{}}
if s.cfg.Names == nil {
writeJSON(w, http.StatusOK, out)
return nil
}
inside := s.insideNetworks(s.eng.State())
asked := map[netip.Addr][]string{}
var addrs []netip.Addr
for _, a := range in.Addresses {
ad, err := netip.ParseAddr(a)
if err != nil || !inside(ad.Unmap()) {
continue
}
ad = ad.Unmap()
if _, ok := asked[ad]; !ok {
addrs = append(addrs, ad)
}
asked[ad] = append(asked[ad], a)
}
for a, n := range s.cfg.Names.Names(r.Context(), addrs, time.Second) {
for _, as := range asked[a] {
out.Names[as] = n
}
}
writeJSON(w, http.StatusOK, out)
return nil
}
// maxNames is the most addresses one POST /names may ask about.
const maxNames = 2000
// insideNetworks tells inside addresses: in a network connected to an
// inside zone's interface, or private and not in an external zone's.
func (s *Server) insideNetworks(st *model.State) func(netip.Addr) bool {
external := map[string]bool{}
for _, z := range st.Zones {
external[z.Name] = z.External
}
var in, out []netip.Prefix
for p, zone := range s.connected(st) {
if external[zone] {
out = append(out, p)
} else {
in = append(in, p)
}
}
return func(a netip.Addr) bool {
if a.IsLoopback() || a.IsUnspecified() || a.IsMulticast() || a.IsLinkLocalUnicast() {
return false
}
for _, p := range in {
if p.Contains(a) {
return true
}
}
for _, p := range out {
if p.Contains(a) {
return false
}
}
return a.IsPrivate()
}
}
// closeConnections deletes tracking entries: the rules check those
// connections again with their next packet.
func (s *Server) closeConnections(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
if err := s.refuseInShadow("close connections"); err != nil {
return err
}
ct, err := s.conntrack()
if err != nil {
return err
}
var in api.CloseConnections
if err := decodeJSON(r, &in); err != nil {
return err
}
list := in.Connections
if strings.TrimSpace(in.Search) != "" {
all, err := ct.List(r.Context())
if err != nil {
return fmt.Errorf("listing connections: %w", err)
}
for _, c := range all {
if connMatches(c, in.Search) {
list = append(list, c)
}
}
} else if len(list) == 0 {
return badRequest("say which connections: a list, or a search")
}
closed := 0
for _, c := range list {
if err := ct.Close(r.Context(), c); err != nil {
return fmt.Errorf("closing %s %s -> %s: %w", c.Protocol, c.Source, c.Destination, err)
}
closed++
}
what := in.Search
if what == "" && len(list) == 1 {
what = fmt.Sprintf("%s %s:%d -> %s:%d", list[0].Protocol, list[0].Source, list[0].SourcePort, list[0].Destination, list[0].DestinationPort)
}
s.eng.Audit(r.Context(), p, "connections.close", what, "success", fmt.Sprintf("%d closed", closed))
writeJSON(w, http.StatusOK, map[string]int{"closed": closed})
return nil
}
// ---- tracing
type tracing struct {
mu sync.Mutex
status api.TraceStatus
tracer *trace.Tracer
cancel context.CancelFunc
}
func (s *Server) startTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.TraceInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Seconds == 0 {
in.Seconds = 60
}
if in.Seconds < 5 || in.Seconds > 600 {
return badRequest("trace for 5 to 600 seconds")
}
script, err := trace.Script(in)
if err != nil {
return badRequest("%v", err)
}
t := &s.tracing
t.mu.Lock()
if t.status.Running {
t.mu.Unlock()
return conflict("a trace is running already (started %s); stop it first", t.status.Started.Format("15:04:05"))
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(in.Seconds)*time.Second)
now := s.nowFn()
t.status = api.TraceStatus{Running: true, Input: in, Started: now, Ends: now.Add(time.Duration(in.Seconds) * time.Second), Packets: []api.TracePacket{}}
t.tracer = &trace.Tracer{Now: s.nowFn}
t.cancel = cancel
t.mu.Unlock()
s.eng.Audit(r.Context(), p, "trace.start", traceWhat(in), "success", fmt.Sprintf("%d seconds", in.Seconds))
go func() {
defer cancel()
err := s.fw.Trace(ctx, script, func(line string) {
t.mu.Lock()
defer t.mu.Unlock()
t.tracer.Line(line)
if t.tracer.Full() {
cancel()
}
})
t.mu.Lock()
defer t.mu.Unlock()
t.status.Running = false
if err != nil {
t.status.Error = err.Error()
s.log.Warn("tracing failed", "err", err)
}
}()
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}
func traceWhat(in api.TraceInput) string {
var parts []string
if in.Address != "" {
parts = append(parts, in.Address)
}
if in.Protocol != "" {
parts = append(parts, in.Protocol)
}
if in.Port != 0 {
parts = append(parts, "port "+strconv.Itoa(in.Port))
}
return strings.Join(parts, " ")
}
// traceStatus is the trace with each step named after Housecarl's object.
func (s *Server) traceStatus() api.TraceStatus {
t := &s.tracing
t.mu.Lock()
out := t.status
if t.tracer != nil {
out.Packets = t.tracer.Packets()
}
t.mu.Unlock()
if out.Packets == nil {
out.Packets = []api.TracePacket{}
}
st := s.eng.State()
for i := range out.Packets {
p := &out.Packets[i]
for j := range p.Steps {
p.Steps[j].Label = markLabel(st, p.Steps[j])
}
p.DecidedBy.Label = markLabel(st, p.DecidedBy)
if p.WouldDrop != nil {
// A copy: the tracer still holds the step.
wd := *p.WouldDrop
wd.Label = markLabel(st, wd)
p.WouldDrop = &wd
}
}
return out
}
// markLabel names the Housecarl object behind a step: "rule input #12
// (ssh)", "built-in: established", "port forward web", "input policy".
func markLabel(st *model.State, step api.TraceStep) string {
if step.Kind == "policy" {
if step.Table == "inet housecarl" {
return step.Chain + " policy"
}
return ""
}
m := step.Mark
kind, id := "", int64(0)
switch {
case m == "":
return ""
case strings.HasPrefix(m, "b-"):
return "built-in: " + strings.TrimPrefix(m, "b-")
case strings.HasPrefix(m, "r"):
kind = model.KindRule
case strings.HasPrefix(m, "f"):
kind = model.KindForward
case strings.HasPrefix(m, "s"):
kind = model.KindSNAT
case strings.HasPrefix(m, "d"):
kind = model.KindRedirect
default:
return ""
}
num := strings.TrimLeft(m[1:], "0123456789")
id, _ = strconv.ParseInt(strings.TrimSuffix(m[1:], num), 10, 64)
if o := st.ByID(kind, id); o != nil {
label := o.Label() // "source NAT #3 (…)", "redirect #4", a forward's name…
switch {
case kind == model.KindRule:
label = "rule " + label
case kind == model.KindForward && !strings.HasPrefix(label, "port forward"):
label = "port forward " + label
}
if num != "" {
label += " (" + strings.TrimPrefix(num, "-") + ")"
}
return label
}
return ""
}
func (s *Server) getTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}
func (s *Server) stopTrace(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
t := &s.tracing
t.mu.Lock()
if t.cancel != nil && t.status.Running {
t.cancel()
}
t.mu.Unlock()
// Let the trace end, so the answer shows it stopped.
for i := 0; i < 50; i++ {
t.mu.Lock()
running := t.status.Running
t.mu.Unlock()
if !running {
break
}
time.Sleep(20 * time.Millisecond)
}
writeJSON(w, http.StatusOK, s.traceStatus())
return nil
}