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>
397 lines
10 KiB
Go
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
|
|
}
|