Files
housecarl/cmd/hc/traffic.go
T
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

368 lines
11 KiB
Go

package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/spf13/cobra"
"housecarl/internal/api"
)
// Traffic as it happens: connections the kernel tracks, and tracing
// packets through the ruleset.
func connectionsCmd() *cobra.Command {
var search string
var limit int
cmd := &cobra.Command{
Use: "connections", Aliases: []string{"conns"}, Short: "The connections the firewall tracks, and closing them",
Long: `Lists the connections the kernel tracks: what the "established" built-in
lets through without checking the rules again. A rule added later doesn't
stop them by itself; close them, and their next packet is checked by the
rules as it stands.
hc connections --search 192.168.1.50
hc connections --search ":443 tcp"
hc connections close --search 192.168.1.50`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cs, err := get[[]api.Connection]("/connections", q("q", search, "limit", strconv.Itoa(limit)))
if err != nil {
return err
}
var addrs []string
for _, c := range cs {
addrs = append(addrs, c.Source, c.Destination)
}
names := lookupNames(addrs...)
return output(cs, func(w io.Writer) {
fmt.Fprintln(w, "PROTO\tFROM\tTO\tNAT\tSTATE\tEXPIRES")
for _, c := range cs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", c.Protocol, named(names, c.Source, hostPort(c.Source, c.SourcePort)),
named(names, c.Destination, hostPort(c.Destination, c.DestinationPort)), natText(c), c.State, fmtSeconds(c.Timeout))
}
})
},
}
cmd.Flags().StringVar(&search, "search", "", "only those with all these words: addresses, :port, protocol, state")
cmd.Flags().IntVar(&limit, "limit", 500, "at most this many")
var csearch string
var yes bool
closeCmd := &cobra.Command{
Use: "close --search TEXT", Short: "Close connections: the rules check them again with their next packet", Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(csearch) == "" {
return fmt.Errorf("say which: --search 192.168.1.50 (see 'hc connections --search' first)")
}
if !confirm("Close every connection matching "+strconvQuote(csearch)+"?", yes) {
return fmt.Errorf("nothing closed")
}
c, err := newClient()
if err != nil {
return err
}
var out map[string]int
if err := c.Do(ctx(), "POST", "/connections/close", nil, api.CloseConnections{Search: csearch}, &out); err != nil {
return err
}
if !flagQuiet {
fmt.Printf("closed %s\n", plural(out["closed"], "connection"))
}
return nil
},
}
closeCmd.Flags().StringVar(&csearch, "search", "", "the connections to close, as for 'hc connections --search'")
closeCmd.Flags().BoolVarP(&yes, "yes", "y", false, "don't ask")
cmd.AddCommand(closeCmd)
return cmd
}
func strconvQuote(s string) string { return strconv.Quote(s) }
func hostPort(addr string, port int) string {
if port == 0 {
return addr
}
if strings.Contains(addr, ":") {
return fmt.Sprintf("[%s]:%d", addr, port)
}
return fmt.Sprintf("%s:%d", addr, port)
}
// natText: "to 192.168.1.10:443" (a port forward), "as 203.0.113.10"
// (masqueraded).
func natText(c api.Connection) string {
var parts []string
if c.DNATTo != "" {
port := c.DNATPort
if port == 0 {
port = c.DestinationPort
}
parts = append(parts, "to "+hostPort(c.DNATTo, port))
}
if c.SNATTo != "" {
parts = append(parts, "as "+c.SNATTo)
}
return strings.Join(parts, ", ")
}
func traceCmd() *cobra.Command {
var in api.TraceInput
cmd := &cobra.Command{
Use: "trace", Short: "Follow packets through every table's rules: what each met, and what decided",
Long: `Traces packets from or to an address, on a port (either way), through the
whole ruleset, Housecarl's and every other table's, for a while: each
rule and policy they meet, and what accepted or dropped them. Ctrl-C
stops it.
hc trace --address 192.168.1.50 --port 443
hc trace --port 22 --seconds 120
hc trace --address 2001:db8:1::/64 --protocol icmpv6`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var st api.TraceStatus
if err := c.Do(ctx(), "POST", "/trace", nil, in, &st); err != nil {
return err
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "tracing until %s (Ctrl-C stops)\n", st.Ends.Local().Format("15:04:05"))
}
sig, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
shown := 0
for {
select {
case <-sig.Done():
c.Do(ctx(), "DELETE", "/trace", nil, nil, &st)
case <-time.After(time.Second):
if err := c.Do(ctx(), "GET", "/trace", nil, nil, &st); err != nil {
return err
}
}
for ; shown < len(st.Packets); shown++ {
printPacket(os.Stdout, st.Packets[shown])
}
if !st.Running || sig.Err() != nil {
break
}
}
if st.Error != "" {
return fmt.Errorf("the trace failed: %s", st.Error)
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, "%d packets traced\n", len(st.Packets))
}
return nil
},
}
f := cmd.Flags()
f.StringVar(&in.Address, "address", "", "packets from or to this address or prefix")
f.IntVar(&in.Port, "port", 0, "packets from or to this port")
f.StringVar(&in.Protocol, "protocol", "", "tcp, udp, sctp, icmp or icmpv6 (default any)")
f.IntVar(&in.Seconds, "seconds", 60, "how long (5 to 600)")
return cmd
}
// printPacket writes a traced packet: its headers, each step, its fate.
func printPacket(w io.Writer, p api.TracePacket) {
what := p.Protocol
if p.TCPFlags != "" {
what += " " + p.TCPFlags
}
if p.ICMPType != "" {
what += " " + p.ICMPType
}
where := ""
if p.In != "" {
where += " in " + p.In
}
if p.Out != "" {
where += " out " + p.Out
}
fmt.Fprintf(w, "%s %s -> %s %s%s\n", p.Time.Local().Format("15:04:05.000"), hostPort(p.Source, p.SourcePort), hostPort(p.Destination, p.DestinationPort), what, where)
for _, s := range p.Steps {
name := s.Label
if name == "" {
if s.Kind == "policy" {
name = s.Chain + " policy"
} else {
name = s.Rule
}
}
fmt.Fprintf(w, " %s %s: %s -> %s\n", s.Table, s.Chain, name, s.Verdict)
}
decided := p.DecidedBy.Label
if decided == "" {
decided = strings.TrimSpace(p.DecidedBy.Table + " " + p.DecidedBy.Chain + " " + p.DecidedBy.Kind)
}
if decided == "" {
fmt.Fprintf(w, " => %s\n", strings.ToUpper(p.Verdict))
} else {
fmt.Fprintf(w, " => %s by %s\n", strings.ToUpper(p.Verdict), decided)
}
if wd := p.WouldDrop; wd != nil {
name := wd.Label
if name == "" {
name = strings.TrimSpace(wd.Chain + " " + wd.Mark)
}
fmt.Fprintf(w, " => shadow mode: Housecarl would have dropped it, by %s\n", name)
}
}
func matrixCmd() *cobra.Command {
return &cobra.Command{
Use: "matrix [FROM TO]", Short: "What the rules do to new connections between zones",
Long: `Shows, for each pair of zones (and the firewall itself), what happens to a
new connection: allowed or blocked, decided by a rule or the policy, and
how many rules decide for part of the traffic first (+N: exceptions,
such as some services or addresses). With FROM and TO (a zone, or
"firewall"), that pair's exceptions in order, and what decides the rest.
hc matrix
hc matrix guest wan
hc matrix lan firewall`,
Args: cobra.RangeArgs(0, 2),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 {
return fmt.Errorf("give both: FROM and TO")
}
m, err := get[api.Matrix]("/matrix", nil)
if err != nil {
return err
}
if len(args) == 2 {
for _, c := range m.Cells {
if strings.EqualFold(c.From, args[0]) && strings.EqualFold(c.To, args[1]) {
return output(c, func(w io.Writer) { printCell(w, c) })
}
}
return fmt.Errorf("no pair %s → %s (zones: %s, and firewall)", args[0], args[1], strings.Join(m.Zones, ", "))
}
return output(m, func(w io.Writer) { printMatrix(w, m) })
},
}
}
func printMatrix(w io.Writer, m api.Matrix) {
writeMatrix(w, m, isTerminal(os.Stdout) && flagOutput == "table")
}
// writeMatrix writes the grid, in colour on a terminal.
func writeMatrix(w io.Writer, m api.Matrix, color bool) {
cols := append(append([]string{}, m.Zones...), "firewall")
cells := map[[2]string]api.MatrixCell{}
for _, c := range m.Cells {
cells[[2]string{c.From, c.To}] = c
}
// The grid is padded by hand: tabwriter would count the colours'
// escape codes as text, and misalign the columns.
grid := [][]string{append([]string{"FROM \\ TO"}, cols...)}
kinds := [][]string{make([]string, len(cols)+1)}
same := false
for _, from := range cols {
row, kind := []string{from}, []string{""}
for _, to := range cols {
c, ok := cells[[2]string{from, to}]
if !ok {
row, kind = append(row, "-"), append(kind, "")
continue
}
text := map[string]string{"allowed": "allow", "blocked": "block", "partly": "partly"}[c.Summary]
if c.Allowed > 0 {
text += fmt.Sprintf(" +%d", c.Allowed)
}
if c.Blocked > 0 {
text += fmt.Sprintf(" -%d", c.Blocked)
}
if c.SameZone {
text += " *"
same = true
}
row, kind = append(row, text), append(kind, c.Summary)
}
grid, kinds = append(grid, row), append(kinds, kind)
}
width := make([]int, len(cols)+1)
for _, row := range grid {
for i, t := range row {
width[i] = max(width[i], utf8.RuneCountInString(t))
}
}
paint := map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m", "partly": "\x1b[33m"}
for r, row := range grid {
var b strings.Builder
for i, t := range row {
pad := strings.Repeat(" ", width[i]-utf8.RuneCountInString(t))
if i == len(row)-1 {
pad = ""
} else {
pad += " "
}
if p := paint[kinds[r][i]]; color && p != "" {
t = p + t + "\x1b[0m"
}
b.WriteString(t + pad)
}
fmt.Fprintln(w, b.String())
}
// What the partly open pairs let through.
var open [][2]string
for _, from := range cols {
for _, to := range cols {
c, ok := cells[[2]string{from, to}]
if !ok || len(c.Open) == 0 || c.SameZone {
continue
}
items := c.Open
more := ""
if len(items) > 6 {
items, more = items[:6], fmt.Sprintf("; %d more", len(c.Open)-6)
}
open = append(open, [2]string{from + " → " + to, strings.Join(items, "; ") + more})
}
}
if len(open) > 0 {
fmt.Fprintln(w, "\nLet through for part of the traffic:")
for _, o := range open {
fmt.Fprintf(w, " %s\t%s\n", o[0], o[1])
}
}
fmt.Fprintln(w, "\nallow, block: every new connection; partly: some are let through, some not.")
fmt.Fprintln(w, "+N: rules that let part of the traffic through; -N: rules that block part of it. 'hc matrix FROM TO' lists them.")
if same {
fmt.Fprintln(w, "*: a zone to itself: only traffic routed between its interfaces passes the firewall.")
}
}
func printCell(w io.Writer, c api.MatrixCell) {
summary := map[string]string{"allowed": "all allowed", "blocked": "all blocked", "partly": "partly allowed"}[c.Summary]
fmt.Fprintf(w, "%s → %s (the %s chain), new connections: %s\n", c.From, c.To, c.Chain, summary)
if c.Note != "" {
fmt.Fprintf(w, " (%s)\n", c.Note)
}
hits := func(h *uint64) string {
if h == nil {
return "-"
}
return fmt.Sprintf("%d", *h)
}
fmt.Fprintln(w, " VERDICT\tWHAT\tBY\tHITS")
for _, e := range c.Exceptions {
fmt.Fprintf(w, " %s\t%s\t%s\t%s\n", e.Verdict, e.What, e.Label, hits(e.Hits))
}
fmt.Fprintf(w, " %s\tthe rest\t%s\t%s\n", c.Verdict, c.DecidedBy, hits(c.Hits))
fmt.Fprintln(w, "\nHits: each rule's packets since housecarld started, in all its traffic (a rule can cover several pairs).")
}