internal/matrix walks each pair's rules in order, as nftables would, for new connections: zone → zone (forward), zone → the firewall (input) and back (output), with the built-ins that act on new connections. A rule for the whole pair decides it; one for part of its traffic (addresses, services, times, rates, a quota) is an exception; the rest falls to the policy. Rules whose addresses all lie in networks connected to other zones are left out of a pair. GET /matrix (with ?changeset, pending changes), hc matrix [FROM TO], and a Matrix page whose cells open their rules. e2e: 166 checks, among them the matrix of the maintainer's imported ruleset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
8.7 KiB
Go
284 lines
8.7 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)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
color := isTerminal(os.Stdout) && flagOutput == "table"
|
|
fmt.Fprint(w, "FROM \\ TO")
|
|
for _, c := range cols {
|
|
fmt.Fprintf(w, "\t%s", c)
|
|
}
|
|
fmt.Fprintln(w)
|
|
for _, from := range cols {
|
|
fmt.Fprint(w, from)
|
|
for _, to := range cols {
|
|
c, ok := cells[[2]string{from, to}]
|
|
text := "-"
|
|
if ok {
|
|
text = map[string]string{"allowed": "allow", "blocked": "block"}[c.Verdict]
|
|
if n := len(c.Exceptions); n > 0 {
|
|
text += fmt.Sprintf(" +%d", n)
|
|
}
|
|
if color {
|
|
text = map[string]string{"allowed": "\x1b[32m", "blocked": "\x1b[31m"}[c.Verdict] + text + "\x1b[0m"
|
|
}
|
|
}
|
|
fmt.Fprintf(w, "\t%s", text)
|
|
}
|
|
fmt.Fprintln(w)
|
|
}
|
|
fmt.Fprintln(w, "\nallow/block: new connections no exception covers; +N: rules deciding for part of the traffic first ('hc matrix FROM TO')")
|
|
}
|
|
|
|
func printCell(w io.Writer, c api.MatrixCell) {
|
|
fmt.Fprintf(w, "%s → %s (the %s chain), new connections:\n", c.From, c.To, c.Chain)
|
|
for _, e := range c.Exceptions {
|
|
fmt.Fprintf(w, " %s\t%s\t%s\n", e.Verdict, e.What, e.Label)
|
|
}
|
|
fmt.Fprintf(w, " %s\tthe rest\t%s\n", c.Verdict, c.DecidedBy)
|
|
}
|