absent: true on a package has the host remove it through the machine's own package manager when it is installed and leave alone a machine that never had it; read back either way. Undeclaring a package still removes nothing. A found firewall whose command is gone is recorded as removed, said once, and asked nothing of.
336 lines
10 KiB
Go
336 lines
10 KiB
Go
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"
|
|
)
|