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) } }