- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
6.2 KiB
Go
209 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
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, hostPort(c.Source, c.SourcePort), 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)
|
|
}
|
|
}
|