package firewall import ( "context" "fmt" "regexp" "sort" "strings" ) // What filters a machine, said with an owner (novox/hq ADR 0168). // // "The firewall found" names one front end, and a machine carries rules from several sources: the // front end's own, the container runtime's plumbing, a ban list, the mesh's own tables, and whatever // a predecessor installed directly — on both machines of the first mesh, in the user chain the // runtime leaves for an administrator, where the mesh's reader of rules counted it as the runtime's. // So the host reports every table and chain that refuses traffic, each with whose it is, and the // mesh says truthfully what filters a converged machine. It removes none of it. // Owners of a refusal. const ( // OwnerMesh is the mesh's own tables: the derived filter and the guard. OwnerMesh = "mesh" // OwnerFoundFirewall is the front end found on the machine — ufw's chains. OwnerFoundFirewall = "found-firewall" // OwnerRuntime is the container runtime's own plumbing: its chains, the forward policy it sets // when it turns forwarding on, its guard against reaching a container's address from off its // bridge. Not the user chain it leaves for an administrator. OwnerRuntime = "runtime" // OwnerBan is a refusal that names the sources it refuses, in a chain that accepts nothing — a // ban list, which is not a firewall. OwnerBan = "ban" // OwnerOther is everything else: rules the mesh did not write and cannot attribute. Where a // predecessor's rules live. OwnerOther = "other" ) // A Filter is one place on the machine that refuses traffic: a chain of a table, or a chain of the // legacy filter, with its owner and what it refuses in one line. type Filter struct { // Where names the chain: "table ip filter, chain DOCKER-USER", or "chain HAL-MESH-ONLY // (iptables-legacy)". Where string `json:"where"` // Owner is one of the owners above. Owner string `json:"owner"` // Refuses is the first refusing line, counters stripped, and how many more there are. Refuses string `json:"refuses"` table, chain string } // userChain is the chain the container runtime creates empty and leaves for an administrator's // rules, consulted before its own forwarding. Nothing in it is the runtime's. const userChain = "DOCKER-USER" // Filters classifies every refusing chain of an `nft list ruleset` and of the legacy filter's `-S` // listings (by tool: iptables-legacy, ip6tables-legacy), in the order they appear. func Filters(ruleset string, legacy map[string]string, ufwActive bool) []Filter { var out []Filter r := parseNft(ruleset) refusing := map[string][]nftRule{} // by "table\x00chain" for _, rule := range r.refusals { k := rule.table + "\x00" + rule.chain refusing[k] = append(refusing[k], rule) } for _, k := range r.chainOrder { c := r.chains[k] table, chain, _ := strings.Cut(k, "\x00") rules := refusing[k] if !c.dropping && len(rules) == 0 { continue } f := Filter{table: table, chain: chain, Where: "table " + table + ", chain " + chain} switch { case table == MeshTable || table == "inet mesh_guard": f.Owner = OwnerMesh case strings.HasPrefix(chain, "ufw"): f.Owner = OwnerFoundFirewall if !ufwActive { // Left behind by a retired front end, and still refusing: not ufw's any more in // any sense that matters, since nothing maintains it. f.Owner = OwnerOther } case chain == userChain: f.Owner = OwnerOther case c.dropping && (r.managed[table] || iptablesTable(table)) && runtimes(table, chain, c.policyLine): f.Owner = OwnerRuntime case len(rules) > 0 && (r.managed[table] || iptablesTable(table)) && allRuntimes(table, chain, rules): f.Owner = OwnerRuntime case len(rules) > 0 && allBans(r, rules): f.Owner = OwnerBan case c.dropping && !iptablesTable(table) && !r.managed[table] && len(rules) == 0: // A table of its own whose base chain drops by policy: a firewall nobody declared. f.Owner = OwnerOther default: f.Owner = OwnerOther } if ufwActive && (r.managed[table] || iptablesTable(table)) && f.Owner == OwnerOther && len(rules) == 0 && c.dropping { // A base chain ufw set to drop while it is in force is ufw's. f.Owner = OwnerFoundFirewall } f.Refuses = refusesLine(c, rules) out = append(out, f) } tools := make([]string, 0, len(legacy)) for tool := range legacy { tools = append(tools, tool) } sort.Strings(tools) for _, tool := range tools { out = append(out, legacyFilters(legacy[tool], tool, ufwActive)...) } return out } // allRuntimes is whether every refusal in a chain is the runtime's own. func allRuntimes(table, chain string, rules []nftRule) bool { for _, rule := range rules { if !runtimes(table, chain, rule.line) { return false } } return true } // allBans is whether every refusal in a chain only bans the sources it names. func allBans(r *nftRuleset, rules []nftRule) bool { for _, rule := range rules { if !r.onlyBans(rule) { return false } } return true } var counters = regexp.MustCompile(`\s*counter packets \d+ bytes \d+`) // refusesLine is one line a person reads: the policy when the chain drops by policy, else the first // refusing rule with its counters stripped, and how many more there are. func refusesLine(c *nftChain, rules []nftRule) string { var parts []string if c.dropping { parts = append(parts, "policy drop") } if len(rules) > 0 { line := strings.TrimSpace(counters.ReplaceAllString(rules[0].line, "")) if len(rules) > 1 { line += fmt.Sprintf(" (and %d more)", len(rules)-1) } parts = append(parts, line) } return strings.Join(parts, "; ") } // legacyFilters classifies the chains of an `iptables-legacy -S` listing that refuse. func legacyFilters(rules, tool string, ufwActive bool) []Filter { policy := map[string]string{} accepting := map[string]bool{} jumpedFrom := map[string][]string{} for _, line := range strings.Split(rules, "\n") { fields := strings.Fields(line) if len(fields) < 3 { continue } switch fields[0] { case "-P": policy[fields[1]] = fields[2] case "-A": for i, f := range fields { if (f == "-j" || f == "-g") && i+1 < len(fields) { switch fields[i+1] { case "ACCEPT": accepting[fields[1]] = true case "DROP", "REJECT", "RETURN", "LOG": default: jumpedFrom[fields[i+1]] = append(jumpedFrom[fields[i+1]], fields[1]) } } } } } var entered func(chain string, seen map[string]bool) bool entered = func(chain string, seen map[string]bool) bool { if seen[chain] || accepting[chain] || len(jumpedFrom[chain]) == 0 { return false } seen[chain] = true for _, from := range jumpedFrom[chain] { if p, builtIn := policy[from]; builtIn { if p != "ACCEPT" { return false } continue } if !entered(from, seen) { return false } } return true } ban := func(chain, line string) bool { return bansSources(line) && entered(chain, map[string]bool{}) } type seen struct { owner string lines []string } chains := map[string]*seen{} var order []string note := func(chain, owner, line string) { s := chains[chain] if s == nil { s = &seen{owner: owner} chains[chain] = s order = append(order, chain) } if owner == OwnerOther || s.owner == "" { s.owner = owner } s.lines = append(s.lines, line) } for _, line := range strings.Split(rules, "\n") { fields := strings.Fields(line) if len(fields) < 3 { continue } chain := fields[1] switch fields[0] { case "-P": if fields[2] != "DROP" { continue } owner := OwnerOther if chain == "FORWARD" { owner = OwnerRuntime } if ufwActive { owner = OwnerFoundFirewall } note(chain, owner, "policy DROP") case "-A": refuses := false for i, f := range fields { if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") { refuses = true } } if !refuses { continue } owner := OwnerOther switch { case strings.HasPrefix(chain, "ufw"): owner = OwnerFoundFirewall if !ufwActive { owner = OwnerOther } case chain != userChain && strings.HasPrefix(chain, "DOCKER"): owner = OwnerRuntime case ban(chain, line): owner = OwnerBan } note(chain, owner, strings.TrimSpace(line)) } } var out []Filter for _, chain := range order { s := chains[chain] refuses := s.lines[0] if len(s.lines) > 1 { refuses += fmt.Sprintf(" (and %d more)", len(s.lines)-1) } out = append(out, Filter{Where: "chain " + chain + " (" + tool + ")", Owner: s.owner, Refuses: refuses}) } return out } // Collect reads what filters this machine now: its nftables ruleset and, where the legacy tools // exist, their listings. A machine without nft is read through iptables, as Detect reads it. func Collect(ctx context.Context, run Runner, ufwActive bool) ([]Filter, error) { ruleset := "" noNft := false out, err := run(ctx, "nft", "list", "ruleset") switch { case err == nil: ruleset = out case missing(err): noNft = true default: return nil, fmt.Errorf("cannot read this machine's packet filter: %w", err) } legacy := map[string]string{} tools := []string{"iptables-legacy", "ip6tables-legacy"} if noNft { tools = append(tools, "iptables", "ip6tables") } for _, tool := range tools { if out, err := run(ctx, tool, "-S"); err == nil && strings.TrimSpace(out) != "" { legacy[tool] = out } } return Filters(ruleset, legacy, ufwActive), nil } // Alone is whether a machine is filtered by the mesh alone: nothing in the list but the mesh's // own tables, the runtime's plumbing and bans (novox/hq ADR 0168). func Alone(filters []Filter) bool { for _, f := range filters { if f.Owner == OwnerOther || f.Owner == OwnerFoundFirewall { return false } } return true } // Active says whether ufw is in force on this machine now. A machine without ufw is not. func Active(ctx context.Context, run Runner) bool { out, err := run(ctx, "ufw", "status") return err == nil && statusActive(out) } // Installed says whether ufw is on this machine at all: a command that is not there is a front end // that was uninstalled (novox/hq ADR 0175), not one that is silent. func Installed(ctx context.Context, run Runner) bool { _, err := run(ctx, "ufw", "status") return !missing(err) } // Retirements of a found firewall, as the host records them. const ( RetiredByMesh = "mesh" RetiredFoundSo = "found-inactive" // RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0175). RetiredRemoved = "removed" )