Converge openings through the firewall an adopted node was found with, and retire it only when the node converges (hq ADR 0100)
This commit is contained in:
+29
-2
@@ -150,8 +150,21 @@ func ApplyKeeping(
|
||||
declared[r.Identity()] = true
|
||||
}
|
||||
|
||||
// Which firewall is found here, before anything else, since an unsupported one refuses the
|
||||
// whole declaration (novox/hq ADR 0100). Nothing for a converged node.
|
||||
fw, err := foundFirewall(ctx, d, &known, run, log)
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: "the firewall found on this machine", Err: err, Done: report}
|
||||
}
|
||||
|
||||
for _, orphan := range known.Orphans(declared, origin) {
|
||||
action, detail, err := remove(ctx, sys, orphan, run)
|
||||
var action, detail string
|
||||
var err error
|
||||
if declaration.Type(orphan.Type) == declaration.TypeOpening {
|
||||
action, detail, err = removeOpening(ctx, orphan, run, known.Firewall)
|
||||
} else {
|
||||
action, detail, err = remove(ctx, sys, orphan, run)
|
||||
}
|
||||
if err != nil {
|
||||
return report, known, &Error{Resource: orphan.ID, Err: err, Done: report}
|
||||
}
|
||||
@@ -235,7 +248,13 @@ func ApplyKeeping(
|
||||
}
|
||||
|
||||
was, _ := known.Find(resource.Identity())
|
||||
outcome, err := applyOne(ctx, sys, resource, run, changed, declares, was, unseal)
|
||||
var outcome Outcome
|
||||
var err error
|
||||
if o, isOpening := resource.(*declaration.Opening); isOpening {
|
||||
outcome, err = applyOpening(ctx, o, run, fw)
|
||||
} else {
|
||||
outcome, err = applyOne(ctx, sys, resource, run, changed, declares, was, unseal)
|
||||
}
|
||||
if err != nil {
|
||||
failed := &Error{Resource: resource.Identity(), Err: err, Done: report}
|
||||
failures = append(failures, failed)
|
||||
@@ -293,6 +312,14 @@ func ApplyKeeping(
|
||||
}
|
||||
}
|
||||
|
||||
// A converged node whose found firewall was in force retires it only now, once everything —
|
||||
// the mesh's derived filter among it — applied cleanly (novox/hq ADR 0100).
|
||||
if len(failures) == 0 {
|
||||
if err := retireFirewall(ctx, d, &known, run, log); err != nil {
|
||||
return report, known, &Error{Resource: "the firewall found on this machine", Err: err, Done: report}
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
// The first, carrying everything that did happen. One error is what the caller reports
|
||||
// and what a person reads first; the rest are in the report, which is what the mesh
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/firewall"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// foundFirewall settles, before anything else in an apply, which firewall this node has — and on
|
||||
// an adopted node that the mesh had converged, puts it back in force first (novox/hq ADR 0100).
|
||||
//
|
||||
// Only an adopted node asks. It is detected on every apply rather than remembered, so a firewall
|
||||
// switched on after adoption is spoken to from the next reconcile; what is remembered is what was
|
||||
// found first, and whether the mesh retired it. An unsupported firewall refuses the whole
|
||||
// declaration: the mesh could neither open what it needs through it nor say what it would close.
|
||||
func foundFirewall(ctx context.Context, d *declaration.Declaration, known *store.State, run Runner,
|
||||
log func(string)) (firewall.Kind, error) {
|
||||
if d.Adoption == nil {
|
||||
return "", nil
|
||||
}
|
||||
rec := known.Firewall
|
||||
if rec != nil && rec.DisabledByMesh && rec.Kind == string(firewall.UFW) {
|
||||
// Returned to adopted: the found firewall is enabled again before the openings are
|
||||
// converged through it, and the derived filter is gone with this declaration.
|
||||
if err := firewall.Enable(ctx, run); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rec.DisabledByMesh = false
|
||||
log(" enabled ufw again: this node is adopted, and the firewall found on it is in force")
|
||||
}
|
||||
kind, name, err := firewall.Detect(ctx, run)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind == firewall.Unsupported {
|
||||
return "", fmt.Errorf(
|
||||
"this machine is filtered by %s, and no host speaks that firewall yet. An adopted node "+
|
||||
"keeps the firewall it was found with, so the mesh could neither open what it needs "+
|
||||
"through it nor say what it would close; this declaration is refused whole", name)
|
||||
}
|
||||
if rec == nil {
|
||||
rec = &store.FoundFirewall{Kind: string(kind), WasActive: kind == firewall.UFW,
|
||||
FoundAt: time.Now().UTC()}
|
||||
} else {
|
||||
rec.Kind = string(kind)
|
||||
rec.WasActive = rec.WasActive || kind == firewall.UFW
|
||||
}
|
||||
known.Firewall = rec
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// retireFirewall disables the found firewall once a converged declaration has applied cleanly,
|
||||
// which is when the mesh's derived filter has taken its place. Disabled, never flushed: its
|
||||
// configuration stays on disk for a return to adopted, and the container runtime's rules are not
|
||||
// its to take.
|
||||
func retireFirewall(ctx context.Context, d *declaration.Declaration, known *store.State, run Runner,
|
||||
log func(string)) error {
|
||||
rec := known.Firewall
|
||||
if d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive ||
|
||||
rec.DisabledByMesh {
|
||||
return nil
|
||||
}
|
||||
if err := firewall.Disable(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
rec.DisabledByMesh = true
|
||||
log(" disabled ufw: this node is converged and filtered by the mesh; ufw's configuration is left on disk")
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOpening makes one opening true through the firewall found here.
|
||||
func applyOpening(ctx context.Context, o *declaration.Opening, run Runner, kind firewall.Kind) (Outcome, error) {
|
||||
out := begin(o)
|
||||
switch kind {
|
||||
case firewall.None:
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "no firewall found; nothing filters this port"
|
||||
return out, nil
|
||||
case firewall.UFW:
|
||||
action, err := firewall.Converge(ctx, run, o)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Action = action
|
||||
out.Detail = "through ufw, marked " + firewall.Mark(o)
|
||||
return out, nil
|
||||
}
|
||||
return out, fmt.Errorf("no firewall is known for this node, so %s cannot be opened", o.Target())
|
||||
}
|
||||
|
||||
// removeOpening deletes the rules the mesh marked for an opening no longer declared, and nothing
|
||||
// the machine had before.
|
||||
func removeOpening(ctx context.Context, a store.Applied, run Runner, rec *store.FoundFirewall) (string, string, error) {
|
||||
if rec == nil || rec.Kind != string(firewall.UFW) {
|
||||
return "forgotten", "no firewall held a rule for it", nil
|
||||
}
|
||||
n, err := firewall.Remove(ctx, run, a.ID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if n == 0 {
|
||||
return "forgotten", "ufw held no rule marked for it", nil
|
||||
}
|
||||
return "removed", fmt.Sprintf("%d ufw rule(s) marked as the mesh's deleted", n), nil
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0100: the firewall found on an adopted node stays in force; converging the
|
||||
// node retires it by disabling it, and returning the node to adopted enables it again.
|
||||
|
||||
type ufwMachine struct {
|
||||
installed, active bool
|
||||
rules []string
|
||||
ruleset string
|
||||
asked []string
|
||||
}
|
||||
|
||||
func (u *ufwMachine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
u.asked = append(u.asked, name+" "+strings.Join(args, " "))
|
||||
switch name {
|
||||
case "nft":
|
||||
return u.ruleset, nil
|
||||
case "ufw":
|
||||
if !u.installed {
|
||||
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
|
||||
}
|
||||
default:
|
||||
return "", &exec.Error{Name: name, Err: exec.ErrNotFound}
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
if u.active {
|
||||
return "Status: active\n", nil
|
||||
}
|
||||
return "Status: inactive\n", nil
|
||||
case "show":
|
||||
out := "Added user rules (see 'ufw status' for running firewall):\n"
|
||||
for _, r := range u.rules {
|
||||
out += "ufw " + r + "\n"
|
||||
}
|
||||
return out, nil
|
||||
case "--force":
|
||||
u.active = true
|
||||
return "", nil
|
||||
case "disable":
|
||||
u.active = false
|
||||
return "", nil
|
||||
case "delete":
|
||||
want := strings.Join(args[1:], " ")
|
||||
for i, r := range u.rules {
|
||||
if strings.ReplaceAll(r, "'", "") == want {
|
||||
u.rules = append(u.rules[:i], u.rules[i+1:]...)
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("Could not delete non-existent rule")
|
||||
default:
|
||||
// Printed back the way it was given, with the comment quoted as ufw does.
|
||||
line := strings.Join(args[:len(args)-1], " ") + " '" + args[len(args)-1] + "'"
|
||||
u.rules = append(u.rules, line)
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ufwMachine) index(prefix string) int {
|
||||
for i, a := range u.asked {
|
||||
if strings.HasPrefix(a, prefix) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
const busOpening = `{"id":"adoption.opening-tcp-5671-incoming","type":"opening","port":5671,"protocol":"tcp","from":"everywhere","path":"incoming"}`
|
||||
|
||||
func withConf(dir string) string {
|
||||
return `{"id":"x.conf","type":"file","path":"` + filepath.Join(dir, "x.conf") + `","content":"x\n"}`
|
||||
}
|
||||
|
||||
func applyWith(t *testing.T, d *declaration.Declaration, known store.State, run Runner) (Report, store.State, error) {
|
||||
t.Helper()
|
||||
return ApplyKeeping(context.Background(), archHost(t), d, known, store.OriginDeclared, run, nil, nil,
|
||||
KeepIn(t.TempDir()))
|
||||
}
|
||||
|
||||
func TestAnOpeningOnAMachineWithNoFirewallChangesNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{}
|
||||
report, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o := outcomeOf(report, "adoption.opening-tcp-5671-incoming")
|
||||
if o.Action != "unchanged" || !strings.Contains(o.Detail, "nothing filters this port") {
|
||||
t.Errorf("an opening with no firewall: %+v", o)
|
||||
}
|
||||
if state.Firewall == nil || state.Firewall.Kind != "none" {
|
||||
t.Errorf("the firewall found was not recorded: %+v", state.Firewall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnsupportedFirewallRefusesTheWholeDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{ruleset: "table inet filter {\n\tchain input {\n\t\ttype filter hook input priority filter; policy drop;\n\t}\n}\n"}
|
||||
_, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run)
|
||||
if err == nil || !strings.Contains(err.Error(), "no host speaks that firewall") {
|
||||
t.Fatalf("an unsupported firewall was not refused: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "x.conf")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Error("part of a refused declaration was applied")
|
||||
}
|
||||
if state.Firewall != nil {
|
||||
t.Errorf("an unsupported firewall was recorded: %+v", state.Firewall)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvergingRetiresTheFoundFirewallAndReturningRestoresIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{installed: true, active: true, rules: []string{"allow 22/tcp"}}
|
||||
|
||||
// Adopted: the opening goes through ufw.
|
||||
_, state, err := applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), store.State{}, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(u.rules) != 2 || !u.active {
|
||||
t.Fatalf("adopted: rules %v, active %v", u.rules, u.active)
|
||||
}
|
||||
if state.Firewall == nil || state.Firewall.Kind != "ufw" || !state.Firewall.WasActive {
|
||||
t.Fatalf("adopted: firewall recorded as %+v", state.Firewall)
|
||||
}
|
||||
|
||||
// Converged: the opening's rule goes, and only then is ufw disabled — never reset.
|
||||
u.asked = nil
|
||||
converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`)
|
||||
_, state, err = applyWith(t, converged, state, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.active || !state.Firewall.DisabledByMesh {
|
||||
t.Fatalf("converged: ufw still active (%v) or not recorded as retired (%+v)", u.active, state.Firewall)
|
||||
}
|
||||
if len(u.rules) != 1 || u.rules[0] != "allow 22/tcp" {
|
||||
t.Errorf("converged: the operator's rules were touched, or the mesh's left: %v", u.rules)
|
||||
}
|
||||
if del, dis := u.index("ufw delete"), u.index("ufw disable"); del < 0 || dis < del {
|
||||
t.Errorf("converged: the opening was not removed before ufw was disabled: %v", u.asked)
|
||||
}
|
||||
for _, a := range u.asked {
|
||||
if strings.Contains(a, "reset") {
|
||||
t.Errorf("converged: ufw was reset: %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Converged again: nothing more to retire.
|
||||
u.asked = nil
|
||||
if _, state, err = applyWith(t, converged, state, u.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.index("ufw") >= 0 {
|
||||
t.Errorf("a converged node kept talking to a retired ufw: %v", u.asked)
|
||||
}
|
||||
|
||||
// Returned to adopted: ufw is enabled before the opening is converged through it.
|
||||
u.asked = nil
|
||||
_, state, err = applyWith(t, adopted(t, `{"taken":[]}`, busOpening+","+withConf(dir)), state, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.active || state.Firewall.DisabledByMesh {
|
||||
t.Fatalf("returned: ufw active %v, record %+v", u.active, state.Firewall)
|
||||
}
|
||||
if en, add := u.index("ufw --force enable"), u.index("ufw allow"); en < 0 || add < en {
|
||||
t.Errorf("returned: ufw was not enabled before the opening was added: %v", u.asked)
|
||||
}
|
||||
if len(u.rules) != 2 {
|
||||
t.Errorf("returned: the opening was not converged again: %v", u.rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConvergedNodeThatWasNeverAdoptedNeverAsksAboutAFirewall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{installed: true, active: true}
|
||||
if _, _, err := applyWith(t, parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`), store.State{}, u.run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(u.asked) != 0 {
|
||||
t.Errorf("a converged apply asked the machine about its firewall: %v", u.asked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOpeningOnAConvergedNodeIsRefused(t *testing.T) {
|
||||
if _, err := declaration.Parse([]byte(`{"declaration":1,"resources":[` + busOpening + `]}`)); err == nil {
|
||||
t.Error("an opening was accepted on a node the declaration does not say is adopted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user