Every table and chain that refuses traffic is reported with whose it is: the mesh's, the found firewall's, the container runtime's own, a ban, or other — the runtime's user chain is other, which is where both predecessors kept their rules, in the legacy filter on one machine and invisible to the mesh. Adoption's threshold does not move; a converged machine's report grows by its filters and its found firewall's state. Convergence is a state the host keeps: a found firewall enabled again is retired again and said; a reconcile that finds it inactive records that it was found so, never that the mesh did it; a step skipped after a failed apply is said. A retirement the mesh began and did not finish is finished. Fixtures are rulesets captured from three machines of the first mesh.
951 lines
30 KiB
Go
951 lines
30 KiB
Go
// Package firewall speaks the firewall found on an adopted node, in that firewall's own terms
|
|
// (novox/hq ADR 0100).
|
|
//
|
|
// **The found firewall stays in force.** On an adopted node the mesh loads nothing that drops by
|
|
// default or holds an accept; what it needs reachable it converges as openings through what it
|
|
// found, marks each rule as its own, and removes only what it marked. It never resets or flushes:
|
|
// the rules the machine already had are the operator's, and they are what keeps it serving.
|
|
package firewall
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// Runner executes a command.
|
|
type Runner = system.Runner
|
|
|
|
// Kind is what firewall a machine has, as far as the mesh is concerned.
|
|
type Kind string
|
|
|
|
const (
|
|
// UFW is an active ufw — the one kind found on the machines measured, and the one spoken.
|
|
UFW Kind = "ufw"
|
|
// None is a machine where nothing refuses anything, which needs no openings.
|
|
None Kind = "none"
|
|
// Unsupported is a firewall no host speaks yet. A machine with one is refused adoption: the
|
|
// mesh could neither open what it needs nor know what it would be closing.
|
|
Unsupported Kind = "unsupported"
|
|
)
|
|
|
|
// deletion is the arguments that delete a rule as `ufw show added` printed it. A route rule is
|
|
// deleted with `route delete …`: ufw refuses `delete route …` as invalid syntax. And ufw answers
|
|
// success when asked to delete a rule it does not hold, so every deletion is read back.
|
|
func deletion(rule string) []string {
|
|
w := words(rule)
|
|
if len(w) > 0 && w[0] == "route" {
|
|
return append([]string{"route", "delete"}, w[1:]...)
|
|
}
|
|
return append([]string{"delete"}, w...)
|
|
}
|
|
|
|
// MeshInterface is the private network's interface, the way an opening from the mesh is known.
|
|
// It must be the controller's overlay interface name.
|
|
const MeshInterface = "mesh0"
|
|
|
|
// Detect says which firewall this machine has. For Unsupported the string names it.
|
|
func Detect(ctx context.Context, run Runner) (Kind, string, error) {
|
|
if out, err := run(ctx, "firewall-cmd", "--state"); err == nil && strings.TrimSpace(out) == "running" {
|
|
return Unsupported, "firewalld", nil
|
|
}
|
|
ufwActive := false
|
|
if out, err := run(ctx, "ufw", "status"); err == nil {
|
|
ufwActive = statusActive(out)
|
|
}
|
|
|
|
noNft := false
|
|
out, err := run(ctx, "nft", "list", "ruleset")
|
|
switch {
|
|
case err == nil:
|
|
if refusing := Refusing(out, ufwActive); len(refusing) > 0 {
|
|
return Unsupported, "nftables rules that refuse traffic, in " + strings.Join(refusing, ", "), nil
|
|
}
|
|
case missing(err):
|
|
// **No nft on this machine does not mean no rules.** iptables-nft writes tables nft would
|
|
// have shown, and a machine whose only tool is iptables answers about them through that.
|
|
// Read as "nothing filters here", a machine with an iptables firewall would be adopted
|
|
// with no openings and nothing would reach the mesh (novox/hq ADR 0100).
|
|
noNft = true
|
|
default:
|
|
return "", "", fmt.Errorf("cannot read this machine's packet filter to know what it has: %w", err)
|
|
}
|
|
|
|
if !ufwActive {
|
|
// iptables with the legacy backend is invisible to nft; and where nft is not installed,
|
|
// the iptables command is the only way to see anything at all.
|
|
tools := []string{"iptables-legacy", "ip6tables-legacy"}
|
|
if noNft {
|
|
tools = append(tools, "iptables", "ip6tables")
|
|
}
|
|
for _, legacy := range tools {
|
|
out, err := run(ctx, legacy, "-S")
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if refusing := RefusingLegacy(out); len(refusing) > 0 {
|
|
return Unsupported, legacy + " rules that refuse traffic, in " + strings.Join(refusing, ", "), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if ufwActive {
|
|
return UFW, "ufw", nil
|
|
}
|
|
return None, "", nil
|
|
}
|
|
|
|
func missing(err error) bool {
|
|
return errors.Is(err, exec.ErrNotFound)
|
|
}
|
|
|
|
func statusActive(out string) bool {
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.HasPrefix(strings.TrimSpace(line), "Status:") {
|
|
return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "Status:")) == "active"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Refusing names the tables of an `nft list ruleset` holding something that refuses traffic — a
|
|
// drop or reject, or a base chain whose policy drops — and that is neither the mesh's own nor the
|
|
// container runtime's. With ufw active, the tables iptables-nft manages are ufw's and the runtime's
|
|
// and are not counted.
|
|
//
|
|
// **A ban is not a firewall.** fail2ban refuses the sources it banned and passes everything else;
|
|
// captured on a lab machine with both of its backends (testdata/fail2ban-nftables.nft,
|
|
// testdata/fail2ban-iptables.nft). The mesh opens nothing through it and it closes nothing the
|
|
// mesh needs, so a refusal that names the sources it refuses, in a table or a chain that accepts
|
|
// nothing and is entered only from chains whose policy accepts, is not counted.
|
|
func Refusing(ruleset string, ufwActive bool) []string {
|
|
var refusing []string
|
|
for _, f := range Filters(ruleset, nil, ufwActive) {
|
|
if f.Owner != OwnerOther || f.chain == userChain {
|
|
// A refusal in the runtime's user chain is reported as *other* and does not refuse
|
|
// adoption (novox/hq ADR 0168, rule 4): both predecessors kept their rules there.
|
|
continue
|
|
}
|
|
name := "table " + f.table
|
|
if len(refusing) == 0 || refusing[len(refusing)-1] != name {
|
|
if !contains(refusing, name) {
|
|
refusing = append(refusing, name)
|
|
}
|
|
}
|
|
}
|
|
return refusing
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// nftRule is one line of a ruleset that refuses, with where it is.
|
|
type nftRule struct{ table, chain, line string }
|
|
|
|
// nftChain is what a parse knows about one chain.
|
|
type nftChain struct {
|
|
base, dropping, accepts bool
|
|
policyLine string
|
|
jumpedFrom []string
|
|
}
|
|
|
|
// nftRuleset is `nft list ruleset`, read: its tables in order, its chains, every refusing line,
|
|
// and which tables iptables-nft manages.
|
|
type nftRuleset struct {
|
|
tables []string
|
|
chains map[string]*nftChain // by "table\x00chain"
|
|
chainOrder []string
|
|
tableAccepts map[string]bool
|
|
refusals []nftRule
|
|
managed map[string]bool
|
|
}
|
|
|
|
func (r *nftRuleset) get(t, c string) *nftChain {
|
|
k := t + "\x00" + c
|
|
if r.chains[k] == nil {
|
|
r.chains[k] = &nftChain{}
|
|
r.chainOrder = append(r.chainOrder, k)
|
|
}
|
|
return r.chains[k]
|
|
}
|
|
|
|
func parseNft(ruleset string) *nftRuleset {
|
|
r := &nftRuleset{chains: map[string]*nftChain{}, tableAccepts: map[string]bool{}, managed: map[string]bool{}}
|
|
var table, chain string
|
|
for _, raw := range strings.Split(ruleset, "\n") {
|
|
line := strings.TrimSpace(raw)
|
|
switch {
|
|
case strings.HasPrefix(line, "# Warning: table ") && strings.Contains(line, "managed by iptables-nft"):
|
|
name := strings.TrimPrefix(line, "# Warning: table ")
|
|
name, _, _ = strings.Cut(name, " is managed")
|
|
r.managed[name] = true
|
|
continue
|
|
case strings.HasPrefix(line, "table "):
|
|
table = strings.TrimSuffix(strings.TrimSpace(strings.TrimPrefix(line, "table ")), "{")
|
|
table = strings.TrimSpace(table)
|
|
r.tables = append(r.tables, table)
|
|
chain = ""
|
|
continue
|
|
case strings.HasPrefix(line, "chain "):
|
|
chain = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(line, "chain "), "{"))
|
|
r.get(table, chain)
|
|
continue
|
|
case strings.HasPrefix(line, "set ") || strings.HasPrefix(line, "map ") ||
|
|
strings.HasPrefix(line, "flowtable "):
|
|
chain = ""
|
|
continue
|
|
case line == "" || line == "}" || strings.HasPrefix(line, "#") || chain == "":
|
|
continue
|
|
}
|
|
c := r.get(table, chain)
|
|
if strings.HasPrefix(line, "type ") {
|
|
c.base = true
|
|
c.policyLine = line
|
|
c.dropping = strings.Contains(line, "policy drop")
|
|
continue
|
|
}
|
|
for _, verb := range []string{"jump ", "goto "} {
|
|
if i := strings.Index(line, verb); i >= 0 {
|
|
target := strings.Fields(line[i+len(verb):])
|
|
if len(target) > 0 {
|
|
r.get(table, target[0]).jumpedFrom = append(r.get(table, target[0]).jumpedFrom, chain)
|
|
}
|
|
}
|
|
}
|
|
if accepts(line) {
|
|
c.accepts = true
|
|
r.tableAccepts[table] = true
|
|
}
|
|
if verdictRefuses(line) {
|
|
r.refusals = append(r.refusals, nftRule{table, chain, line})
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// onlyBans is whether a refusal only refuses the sources it names: in a table that accepts nothing
|
|
// and whose base chains all accept by default, or in a chain that accepts nothing and is entered
|
|
// only from base chains that accept by default.
|
|
func (r *nftRuleset) onlyBans(rule nftRule) bool {
|
|
if !bansSources(rule.line) {
|
|
return false
|
|
}
|
|
allAccepting := true
|
|
for k, c := range r.chains {
|
|
if strings.HasPrefix(k, rule.table+"\x00") && c.base && !strings.Contains(c.policyLine, "policy accept") {
|
|
allAccepting = false
|
|
}
|
|
}
|
|
if !r.tableAccepts[rule.table] && allAccepting {
|
|
return true
|
|
}
|
|
return r.enteredAccepting(rule.table, rule.chain, map[string]bool{})
|
|
}
|
|
|
|
// enteredAccepting is whether a chain accepts nothing and is entered only through chains that
|
|
// accept by default — base chains whose policy accepts, or chains that are themselves entered that
|
|
// way and accept nothing. A ban list jumped to from the runtime's user chain, which the forward
|
|
// chain enters with an accepting policy, is still a ban list.
|
|
func (r *nftRuleset) enteredAccepting(table, chain string, seen map[string]bool) bool {
|
|
if seen[chain] {
|
|
return false
|
|
}
|
|
seen[chain] = true
|
|
c := r.get(table, chain)
|
|
if c.base || c.accepts || len(c.jumpedFrom) == 0 {
|
|
return false
|
|
}
|
|
for _, from := range c.jumpedFrom {
|
|
caller := r.get(table, from)
|
|
if caller.base {
|
|
if !strings.Contains(caller.policyLine, "policy accept") {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if caller.accepts || !r.enteredAccepting(table, from, seen) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// iptablesTable is whether a table is one iptables-nft writes. Named rather than read from the
|
|
// warning nft prints above it, because nft does not print that for every such table: a captured
|
|
// ruleset carried it on ip filter and not on ip raw, where the runtime keeps its drops.
|
|
func iptablesTable(table string) bool {
|
|
family, name, _ := strings.Cut(table, " ")
|
|
if family != "ip" && family != "ip6" {
|
|
return false
|
|
}
|
|
switch name {
|
|
case "filter", "nat", "raw", "mangle", "security":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runtimes is whether a refusal in an iptables-nft table is the container runtime's own: in its
|
|
// DOCKER chains, its forward policy, or its guard against reaching a container's address directly
|
|
// from outside its bridge, in the raw table.
|
|
func runtimes(table, chain, line string) bool {
|
|
_, name, _ := strings.Cut(table, " ")
|
|
switch {
|
|
case strings.HasPrefix(chain, "DOCKER"):
|
|
return true
|
|
case name == "filter" && chain == "FORWARD" && strings.HasPrefix(line, "type "):
|
|
return true
|
|
case name == "raw" && chain == "PREROUTING":
|
|
return strings.Contains(line, "daddr") && strings.Contains(line, "iifname !=")
|
|
}
|
|
return false
|
|
}
|
|
|
|
// iptables-nft prints a REJECT target it cannot translate as `xt target "REJECT"`, measured in
|
|
// testdata/fail2ban-iptables.nft; a refusal written that way is a refusal too.
|
|
var verdict = regexp.MustCompile(`(^|\s)(drop|reject)(\s|$)|xt target "(DROP|REJECT)"`)
|
|
|
|
func verdictRefuses(line string) bool {
|
|
return verdict.MatchString(line)
|
|
}
|
|
|
|
var acceptVerdict = regexp.MustCompile(`(^|\s)accept(\s|;|$)|xt target "ACCEPT"`)
|
|
|
|
func accepts(line string) bool {
|
|
return acceptVerdict.MatchString(line)
|
|
}
|
|
|
|
// bansSources is whether a refusal names the sources it refuses — a set or an address — rather
|
|
// than refusing everyone but some.
|
|
func bansSources(line string) bool {
|
|
f := strings.Fields(line)
|
|
for i, w := range f {
|
|
if (w == "saddr" || w == "-s") && i+1 < len(f) && f[i+1] != "!=" && !strings.HasPrefix(f[i+1], "!") {
|
|
return i == 0 || f[i-1] != "!"
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RefusingLegacy names the chains of an `iptables-legacy -S` that refuse traffic outside the
|
|
// container runtime's own. A ban — a refusal of the sources it names, in a chain that accepts
|
|
// nothing and is entered only from built-in chains whose policy accepts — is not counted, as in
|
|
// Refusing (testdata/fail2ban-iptables-S.txt).
|
|
func RefusingLegacy(rules string) []string {
|
|
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])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ban := func(chain, line string) bool {
|
|
if !bansSources(line) || accepting[chain] || len(jumpedFrom[chain]) == 0 {
|
|
return false
|
|
}
|
|
for _, from := range jumpedFrom[chain] {
|
|
if policy[from] != "ACCEPT" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
var refusing []string
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(rules, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 3 {
|
|
continue
|
|
}
|
|
chain := fields[1]
|
|
refuses := false
|
|
switch fields[0] {
|
|
case "-P":
|
|
refuses = fields[2] == "DROP" && chain != "FORWARD"
|
|
case "-A":
|
|
for i, f := range fields {
|
|
if f == "-j" && i+1 < len(fields) && (fields[i+1] == "DROP" || fields[i+1] == "REJECT") {
|
|
refuses = !strings.HasPrefix(chain, "DOCKER") && !ban(chain, line)
|
|
}
|
|
}
|
|
}
|
|
if refuses && !seen[chain] {
|
|
seen[chain] = true
|
|
refusing = append(refusing, "chain "+chain)
|
|
}
|
|
}
|
|
return refusing
|
|
}
|
|
|
|
// --- ufw ---------------------------------------------------------------------------------------
|
|
|
|
// Mark is the comment every rule the mesh adds carries: whose it is, which opening, and a digest
|
|
// of the rule itself, so a rule the opening no longer describes is recognised as stale without the
|
|
// host having to know how ufw prints a rule back.
|
|
func Mark(o *declaration.Opening) string {
|
|
sum := sha256.Sum256([]byte(strings.Join(Rule(o), " ")))
|
|
return marker(o.ID) + " " + hex.EncodeToString(sum[:])[:8]
|
|
}
|
|
|
|
func marker(id string) string { return "mesh-host " + id }
|
|
|
|
// markedFor is whether a comment is the mesh's, for this opening.
|
|
func markedFor(comment, id string) bool {
|
|
return comment == marker(id) || strings.HasPrefix(comment, marker(id)+" ")
|
|
}
|
|
|
|
// Rule is the ufw rule an opening becomes, without its comment.
|
|
//
|
|
// incoming from everywhere allow proto tcp to any port P
|
|
// incoming from the mesh allow in on mesh0 proto tcp to any port P
|
|
// forwarded route allow [in on mesh0] proto tcp to any port <container port>
|
|
//
|
|
// A forwarded opening names the container's port because ufw's route rules are matched after the
|
|
// runtime's destination translation.
|
|
func Rule(o *declaration.Opening) []string {
|
|
var rule []string
|
|
port := o.Port
|
|
if o.Path == declaration.PathForwarded {
|
|
rule = append(rule, "route")
|
|
port = o.To
|
|
}
|
|
rule = append(rule, "allow")
|
|
if o.From == declaration.FromMesh {
|
|
rule = append(rule, "in", "on", MeshInterface)
|
|
}
|
|
return append(rule, "proto", o.Protocol, "to", "any", "port", strconv.Itoa(port))
|
|
}
|
|
|
|
var commentOf = regexp.MustCompile(`comment '([^']*)'`)
|
|
|
|
// added is every rule `ufw show added` lists, each without its leading "ufw".
|
|
func added(ctx context.Context, run Runner) ([]string, error) {
|
|
out, err := run(ctx, "ufw", "show", "added")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading ufw's rules: %w", err)
|
|
}
|
|
var rules []string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "ufw ") {
|
|
rules = append(rules, strings.TrimPrefix(line, "ufw "))
|
|
}
|
|
}
|
|
return rules, nil
|
|
}
|
|
|
|
func comment(rule string) string {
|
|
m := commentOf.FindStringSubmatch(rule)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return m[1]
|
|
}
|
|
|
|
// words splits a rule as ufw printed it into arguments, keeping a quoted comment whole.
|
|
func words(rule string) []string {
|
|
var out []string
|
|
var cur strings.Builder
|
|
quoted, any := false, false
|
|
for _, r := range rule {
|
|
switch {
|
|
case r == '\'':
|
|
quoted = !quoted
|
|
any = true
|
|
case r == ' ' && !quoted:
|
|
if any {
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
any = false
|
|
}
|
|
default:
|
|
cur.WriteRune(r)
|
|
any = true
|
|
}
|
|
}
|
|
if any {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A ufw rule, as `ufw show added` prints it or as it is given, reduced to what ufw compares.
|
|
//
|
|
// **ufw treats two rules that differ only in their comment as one rule.** Measured on a lab
|
|
// machine (testdata/ufw-comment-only.txt): adding `route allow proto tcp to any port 8080 comment
|
|
// 'mesh-host …'` beside an operator's `route allow 8080/tcp` answers "Rule updated", and the
|
|
// operator's rule now carries the mesh's mark — so removing the opening later would delete the
|
|
// operator's rule. The same holds for an incoming rule and for one with a comment of its own.
|
|
type ufwRule struct {
|
|
route bool
|
|
action, in, out string
|
|
// dir is which way the rule matches: "" (ufw's default, incoming and forwarded), "in" or
|
|
// "out". A rule on the outgoing path admits nothing that arrives.
|
|
dir string
|
|
log string
|
|
from, fromPort, to string
|
|
port, proto, app string
|
|
comment string
|
|
}
|
|
|
|
// parseRule reads a rule in either of ufw's forms — the short `allow 22/tcp` and the long `allow
|
|
// in on mesh0 to any port 5432 proto tcp` — into the fields ufw compares. Not ok for anything it
|
|
// does not recognise, which is then never taken to answer an opening.
|
|
func parseRule(rule string) (ufwRule, bool) {
|
|
r := ufwRule{from: "any", to: "any", comment: comment(rule)}
|
|
// A log type may stand after the action or after the direction; either way it is a property
|
|
// of the rule, not of where it matches. The word after `comment` is the comment, whatever it
|
|
// says.
|
|
var w []string
|
|
all := words(rule)
|
|
for i := 0; i < len(all); i++ {
|
|
switch {
|
|
case all[i] == "comment" && i+1 < len(all):
|
|
w = append(w, all[i], all[i+1])
|
|
i++
|
|
case all[i] == "log" || all[i] == "log-all":
|
|
r.log = all[i]
|
|
default:
|
|
w = append(w, all[i])
|
|
}
|
|
}
|
|
i := 0
|
|
if i < len(w) && w[i] == "route" {
|
|
r.route = true
|
|
i++
|
|
}
|
|
if i >= len(w) {
|
|
return r, false
|
|
}
|
|
switch w[i] {
|
|
case "allow", "deny", "reject", "limit":
|
|
r.action = w[i]
|
|
default:
|
|
return r, false
|
|
}
|
|
i++
|
|
for i < len(w) && (w[i] == "in" || w[i] == "out") {
|
|
dir := w[i]
|
|
i++
|
|
iface := ""
|
|
if i+1 < len(w) && w[i] == "on" {
|
|
iface = w[i+1]
|
|
i += 2
|
|
}
|
|
r.dir = dir
|
|
if dir == "in" {
|
|
r.in = iface
|
|
} else {
|
|
r.out = iface
|
|
}
|
|
}
|
|
if i < len(w) && w[i] != "from" && w[i] != "to" && w[i] != "proto" && w[i] != "comment" &&
|
|
w[i] != "app" && w[i] != "log" && w[i] != "log-all" {
|
|
// The short form: a port with its protocol, a bare port, or an application's name.
|
|
port, proto, hasProto := strings.Cut(w[i], "/")
|
|
if isPorts(port) {
|
|
r.port = port
|
|
if hasProto {
|
|
r.proto = proto
|
|
}
|
|
} else {
|
|
r.app = w[i]
|
|
}
|
|
i++
|
|
}
|
|
for ; i < len(w); i++ {
|
|
next := func() string {
|
|
if i+1 < len(w) {
|
|
i++
|
|
return w[i]
|
|
}
|
|
return ""
|
|
}
|
|
switch w[i] {
|
|
case "from":
|
|
r.from = next()
|
|
if i+1 < len(w) && w[i+1] == "port" {
|
|
i++
|
|
r.fromPort = next()
|
|
}
|
|
case "to":
|
|
r.to = next()
|
|
if i+1 < len(w) && w[i+1] == "port" {
|
|
i++
|
|
r.port = next()
|
|
}
|
|
case "port":
|
|
r.port = next()
|
|
case "proto":
|
|
r.proto = next()
|
|
case "app":
|
|
r.app = next()
|
|
case "comment":
|
|
next()
|
|
case "log", "log-all":
|
|
default:
|
|
return r, false
|
|
}
|
|
}
|
|
if r.proto == "any" {
|
|
r.proto = ""
|
|
}
|
|
// Incoming is ufw's default direction, and it prints `allow in 9005/tcp` back as
|
|
// `allow 9005/tcp` and merges the two — captured in testdata/ufw-direction.txt. An outgoing
|
|
// rule is its own rule and stays one.
|
|
if r.dir == "in" && r.in == "" {
|
|
r.dir = ""
|
|
}
|
|
return r, true
|
|
}
|
|
|
|
func isPorts(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if (c < '0' || c > '9') && c != ':' && c != ',' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// sameAs is whether ufw would take two rules for one: everything equal but the comment, the
|
|
// action and the log type. Adding one beside the other updates it in place — its comment, and its
|
|
// action or log type — rather than adding a second.
|
|
func (r ufwRule) sameAs(o ufwRule) bool {
|
|
r.comment, o.comment = "", ""
|
|
r.action, o.action = "", ""
|
|
r.log, o.log = "", ""
|
|
return r == o
|
|
}
|
|
|
|
// admits is whether a rule already lets through what an opening says: the same path, allowed from
|
|
// any source to any address of this machine, on the opening's port and protocol — or on any
|
|
// protocol — and on any interface, or the private network's for an opening from it.
|
|
func (r ufwRule) admits(o *declaration.Opening) bool {
|
|
want, ok := parseRule(strings.Join(Rule(o), " "))
|
|
if !ok || r.route != want.route || r.action != "allow" || r.app != "" ||
|
|
r.from != "any" || r.fromPort != "" || r.to != "any" {
|
|
return false
|
|
}
|
|
// A rule on the outgoing path lets this machine reach others; it admits nothing that arrives,
|
|
// so it never answers an opening.
|
|
if r.dir == "out" || r.out != "" {
|
|
return false
|
|
}
|
|
if r.proto != "" && r.proto != want.proto {
|
|
return false
|
|
}
|
|
if r.in != "" && r.in != want.in {
|
|
return false
|
|
}
|
|
return portsInclude(r.port, want.port)
|
|
}
|
|
|
|
// portsInclude is whether a ufw port list — 80, 80,443, or 8000:8100 — names a port.
|
|
func portsInclude(list, port string) bool {
|
|
p, err := strconv.Atoi(port)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, part := range strings.Split(list, ",") {
|
|
lo, hi, isRange := strings.Cut(part, ":")
|
|
a, err := strconv.Atoi(lo)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
b := a
|
|
if isRange {
|
|
if b, err = strconv.Atoi(hi); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
if a <= p && p <= b {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Converged is what converging an opening did. SatisfiedBy names the rule already there that
|
|
// answers the opening, when one does; the mesh then adds nothing, and so will remove nothing.
|
|
type Converged struct {
|
|
Action string
|
|
SatisfiedBy string
|
|
}
|
|
|
|
// Converge makes one opening true in ufw: its marked rule present, and any rule marked for it that
|
|
// no longer describes it deleted. Nothing unmarked is touched. The outcome is created, updated or
|
|
// unchanged, read back from ufw rather than assumed.
|
|
//
|
|
// **An opening a rule already answers is not added** (novox/hq ADR 0103). If ufw holds a rule not
|
|
// marked for this opening that already admits what it says — the operator's, or one the mesh
|
|
// added for another opening — the opening is satisfied by it: adding the mesh's would take that
|
|
// rule over if it differs only in its comment, and removing the opening would then delete it.
|
|
func Converge(ctx context.Context, run Runner, o *declaration.Opening) (Converged, error) {
|
|
rules, err := added(ctx, run)
|
|
if err != nil {
|
|
return Converged{}, err
|
|
}
|
|
mark := Mark(o)
|
|
present := false
|
|
var stale []string
|
|
satisfiedBy := ""
|
|
want, _ := parseRule(strings.Join(Rule(o), " "))
|
|
for _, rule := range rules {
|
|
c := comment(rule)
|
|
switch {
|
|
case c == mark:
|
|
present = true
|
|
case markedFor(c, o.ID):
|
|
stale = append(stale, rule)
|
|
default:
|
|
parsed, ok := parseRule(rule)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// ufw would take the mesh's rule for this one and rewrite its action or log type:
|
|
// an operator's refusal, or a limit, would silently become an allow — and removing the
|
|
// opening would then delete it. A plain allow answers the opening; anything else is a
|
|
// conflict the operator decides (novox/hq ADR 0103).
|
|
if parsed.sameAs(want) && (parsed.action != "allow" || parsed.log != "") {
|
|
return Converged{}, fmt.Errorf("ufw holds %q, which ufw takes for the same rule as the "+
|
|
"mesh's opening for %s, differing in what it does; adding the opening would change "+
|
|
"it, so nothing was added. Change or remove that rule, or have the mesh stop "+
|
|
"declaring the opening", rule, o.Target())
|
|
}
|
|
if satisfiedBy == "" && parsed.admits(o) {
|
|
satisfiedBy = rule
|
|
}
|
|
}
|
|
}
|
|
if present && len(stale) == 0 {
|
|
return Converged{Action: "unchanged"}, nil
|
|
}
|
|
for _, rule := range stale {
|
|
if _, err := run(ctx, "ufw", deletion(rule)...); err != nil {
|
|
return Converged{}, fmt.Errorf("deleting the mesh's stale ufw rule %q: %w", rule, err)
|
|
}
|
|
}
|
|
if !present && satisfiedBy != "" {
|
|
after, err := added(ctx, run)
|
|
if err != nil {
|
|
return Converged{}, err
|
|
}
|
|
for _, rule := range after {
|
|
if markedFor(comment(rule), o.ID) {
|
|
return Converged{}, fmt.Errorf("ufw still lists a stale rule marked for %s after deleting it", o.ID)
|
|
}
|
|
}
|
|
action := "unchanged"
|
|
if len(stale) > 0 {
|
|
action = "updated"
|
|
}
|
|
return Converged{Action: action, SatisfiedBy: satisfiedBy}, nil
|
|
}
|
|
if !present {
|
|
args := append(Rule(o), "comment", mark)
|
|
if _, err := run(ctx, "ufw", args...); err != nil {
|
|
return Converged{}, fmt.Errorf("adding the ufw rule for %s: %w", o.Target(), err)
|
|
}
|
|
}
|
|
after, err := added(ctx, run)
|
|
if err != nil {
|
|
return Converged{}, err
|
|
}
|
|
found, leftover := false, 0
|
|
for _, rule := range after {
|
|
c := comment(rule)
|
|
if c == mark {
|
|
found = true
|
|
} else if markedFor(c, o.ID) {
|
|
leftover++
|
|
}
|
|
}
|
|
if !found {
|
|
return Converged{}, fmt.Errorf("ufw was asked for %s and does not list it afterwards", o.Target())
|
|
}
|
|
if leftover > 0 {
|
|
return Converged{}, fmt.Errorf("ufw still lists %d stale rule(s) marked for %s after deleting them", leftover, o.ID)
|
|
}
|
|
if len(stale) > 0 {
|
|
return Converged{Action: "updated"}, nil
|
|
}
|
|
return Converged{Action: "created"}, nil
|
|
}
|
|
|
|
// Remove deletes the rules marked for one opening, and nothing else.
|
|
func Remove(ctx context.Context, run Runner, id string) (int, error) {
|
|
rules, err := added(ctx, run)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
removed := 0
|
|
for _, rule := range rules {
|
|
if !markedFor(comment(rule), id) {
|
|
continue
|
|
}
|
|
if _, err := run(ctx, "ufw", deletion(rule)...); err != nil {
|
|
return removed, fmt.Errorf("deleting the mesh's ufw rule %q: %w", rule, err)
|
|
}
|
|
removed++
|
|
}
|
|
after, err := added(ctx, run)
|
|
if err != nil {
|
|
return removed, err
|
|
}
|
|
for _, rule := range after {
|
|
if markedFor(comment(rule), id) {
|
|
return removed, fmt.Errorf("ufw still lists a rule marked for %s after deleting it", id)
|
|
}
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
// Enable turns ufw back on, as found, and reads back that it is.
|
|
func Enable(ctx context.Context, run Runner) error {
|
|
if _, err := run(ctx, "ufw", "--force", "enable"); err != nil {
|
|
return fmt.Errorf("enabling ufw again: %w", err)
|
|
}
|
|
return expectActive(ctx, run, true)
|
|
}
|
|
|
|
// Disable retires ufw without flushing it: its configuration stays on disk, and the container
|
|
// runtime's rules are not its to remove.
|
|
//
|
|
// **Nor is the forward policy ufw's to open.** Measured on a lab machine running the container
|
|
// runtime with a published port (testdata/ufw-disable-iptables-before.txt and -after.txt):
|
|
// `ufw disable` sets every built-in chain's policy to accept, the forward chain's among them. The
|
|
// runtime had set that one to drop when it turned forwarding on, and it does not set it again while
|
|
// forwarding stays on — not even on a restart. Left so, a retired ufw turns the machine into a
|
|
// router for anyone who can reach it. So each family's forward policy is read before, and one that
|
|
// was drop is put back and read back. before is ForwardPolicies as read before the first attempt.
|
|
func Disable(ctx context.Context, run Runner, before map[string]string) error {
|
|
if _, err := run(ctx, "ufw", "disable"); err != nil {
|
|
return fmt.Errorf("disabling ufw: %w", err)
|
|
}
|
|
if err := expectActive(ctx, run, false); err != nil {
|
|
return err
|
|
}
|
|
for _, tool := range []string{"iptables", "ip6tables"} {
|
|
if before[tool] != "DROP" {
|
|
continue
|
|
}
|
|
if now, ok := forwardPolicy(ctx, run, tool); ok && now == "DROP" {
|
|
continue
|
|
}
|
|
if _, err := run(ctx, tool, "-P", "FORWARD", "DROP"); err != nil {
|
|
return fmt.Errorf("ufw is disabled, and %s's forward policy, which was drop, could not be put back: %w",
|
|
tool, err)
|
|
}
|
|
if now, ok := forwardPolicy(ctx, run, tool); !ok || now != "DROP" {
|
|
return fmt.Errorf("ufw is disabled, and %s's forward policy was put back to drop and reads %q",
|
|
tool, now)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MeshTable is the derived filter's table, the thing that must be in force before the firewall
|
|
// found on a machine is retired.
|
|
const MeshTable = "inet mesh"
|
|
|
|
// MeshTableLoaded asks the machine whether the mesh's own filter is loaded. Read from the machine
|
|
// rather than assumed from the declaration: a table declared and not loaded is exactly the case
|
|
// where disabling the found firewall would leave the machine with nothing.
|
|
func MeshTableLoaded(ctx context.Context, run Runner) (bool, error) {
|
|
out, err := run(ctx, "nft", "list", "tables")
|
|
if err != nil {
|
|
if missing(err) {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("cannot read which tables this machine has loaded: %w", err)
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
rest, ok := strings.CutPrefix(strings.TrimSpace(line), "table "+MeshTable)
|
|
if ok && (rest == "" || strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, "{")) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// ForwardPolicies reads each family's forward policy, by the tool that sets it. Read before ufw is
|
|
// disabled and kept by the caller, so a retirement that fails half-way is retried with what the
|
|
// machine had — not with what the half-done disable left.
|
|
func ForwardPolicies(ctx context.Context, run Runner) map[string]string {
|
|
out := map[string]string{}
|
|
for _, tool := range []string{"iptables", "ip6tables"} {
|
|
if policy, ok := forwardPolicy(ctx, run, tool); ok {
|
|
out[tool] = policy
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// forwardPolicy reads the forward chain's policy the way iptables prints it: "-P FORWARD DROP".
|
|
// Not ok when the tool is absent or says nothing readable.
|
|
func forwardPolicy(ctx context.Context, run Runner, tool string) (string, bool) {
|
|
out, err := run(ctx, tool, "-S", "FORWARD")
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) == 3 && f[0] == "-P" && f[1] == "FORWARD" {
|
|
return f[2], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func expectActive(ctx context.Context, run Runner, want bool) error {
|
|
out, err := run(ctx, "ufw", "status")
|
|
if err != nil {
|
|
return fmt.Errorf("reading ufw's status back: %w", err)
|
|
}
|
|
if statusActive(out) != want {
|
|
state := "inactive"
|
|
if want {
|
|
state = "active"
|
|
}
|
|
return fmt.Errorf("ufw was asked to be %s and says: %s", state, strings.TrimSpace(out))
|
|
}
|
|
return nil
|
|
}
|