Connections, "every packet" rules, and a packet tracer
- 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>
This commit is contained in:
1 parent
98e11a8bec
commit
a45c893830
34 files changed
+1830
-41
No files matched your search
@@ -0,0 +1,208 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user