diff --git a/Dockerfile b/Dockerfile index 3a5adb9..b891a3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ ARG BUILD_DATE=unknown COPY go.mod ./ RUN --mount=type=cache,target=/go/pkg/mod go mod download COPY . . -RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./internal/hostsecurity/security.go && test -f ./web/embed.go || \ +RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./internal/hostsecurity/security.go && test -f ./internal/hostsecurity/firewall_provider.go && test -f ./web/embed.go || \ (echo "ERROR: required source files are missing from Docker build context; check .dockerignore" >&2; exit 1) # Keep the module graph in sync with the actual source tree. This is required for # Go 1.17+ module graph pruning when transitive dependencies must be recorded as diff --git a/README.md b/README.md index 7c31568..39e1110 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Dockwatch v9.4.1 +# Dockwatch v9.5 > Go module: `git.send.nrw/sendnrw/dockwatch` @@ -6,6 +6,19 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`. +## v9.5 firewall-provider layer + +- Host Security firewall management now uses a provider model: **Auto detect / UFW / firewalld / native nftables** +- Auto detect adopts an already-active UFW or firewalld frontend instead of refusing all changes simply because one exists +- multiple truly competing active frontends are still treated as a hard conflict +- UFW rules are added with deterministic `dockwatch:` comments and Dockwatch removes only those managed rules; it never runs `ufw reset` +- firewalld uses the selected native zone and managed rich rules while preserving unrelated services, ports, sources and rich rules +- native nftables keeps the isolated `table inet dockwatch` model and never flushes the global ruleset +- existing provider state/rules are displayed read-only in the firewall editor before changes are applied +- provider selection, default inbound management, firewalld zone selection, ALLOW/DENY/REJECT/LIMIT rules and rule reordering are available in the unified UI +- provider changes retain the timed rollback/explicit commit safety model; rollback restores the previous Dockwatch-managed policy and provider default snapshot where applicable +- package installation/upgrade now installs the selected firewall frontend (`ufw`, `firewalld` or `nftables`) rather than always installing nftables + ## v9.4.1 fixes - fixed monitor creation when no monitor is selected (`state.monitor == null`) @@ -148,20 +161,21 @@ Dockwatch can optionally act as a host-security control plane for the selected l The **System → Host Security** page provides a posture overview and managed workflows for: -**Firewall (nftables)** +**Firewall (Auto / UFW / firewalld / nftables)** -- detect whether nftables is installed and whether Dockwatch's policy is active -- configure an isolated `table inet dockwatch` INPUT policy -- default inbound `ACCEPT` or `DROP` -- stateful established/related allowance, loopback and invalid-state handling -- optional ICMP/ICMPv6 allowance -- trusted IPv4/IPv6 CIDRs -- typed TCP/UDP allow/deny port and port-range rules -- server-side `nft -c` validation before apply -- conflict detection for active UFW/firewalld; Dockwatch refuses to become a second competing firewall owner -- persistence through a Dockwatch-owned systemd unit or OpenRC local script +- **Auto detect** adopts the already-active host firewall frontend: UFW first when it is the sole active frontend, firewalld when it is the sole active frontend, otherwise native nftables is used when available +- an explicit provider can be selected when you intentionally want to migrate or standardize a host +- only genuinely competing active frontends are treated as conflicts; for example UFW + firewalld, or UFW + an already-loaded Dockwatch nftables table +- the editor shows installed/active frontends plus the provider's existing runtime rules/state before any mutation +- common managed policy supports trusted IPv4/IPv6 CIDRs and ordered TCP/UDP **ALLOW / DENY / REJECT / LIMIT** rules +- provider-global default inbound management is an explicit opt-in for UFW/firewalld; nftables keeps its default policy inside Dockwatch's own table +- **UFW:** Dockwatch uses native `ufw` commands, tags its rules with deterministic `dockwatch:` comments and removes only those tagged rules; it never runs `ufw reset` and preserves foreign UFW rules +- **firewalld:** Dockwatch targets a selected zone, uses native rich rules, and preserves unrelated zone services/ports/sources/rich rules; default zone target changes are opt-in +- **nftables:** Dockwatch continues to own only `table inet dockwatch`, validates with `nft -c`, never runs `flush ruleset`, and does not alter Docker NAT/FORWARD chains +- when an enabled policy is applied to an inactive UFW/firewalld provider, Dockwatch activates the selected frontend inside the rollback window; rollback restores the previous active state - **timed rollback** (30–600 seconds, UI default 90 seconds) after apply; changes must be explicitly kept after management connectivity is verified -- no `flush ruleset`, no changes to Docker NAT/FORWARD chains and no arbitrary nftables text accepted from the browser +- package installation/upgrade uses the selected provider package instead of always installing nftables +- browser clients never submit arbitrary firewall command text; the backend renders and executes only the typed policy model **Fail2Ban** @@ -184,7 +198,7 @@ The **System → Host Security** page provides a posture overview and managed wo **Installation and maintenance** -- install or upgrade `nftables`, `fail2ban` and `auditd`/`audit` using the detected host package manager +- install or upgrade the selected firewall provider (`ufw`, `firewalld` or `nftables`), `fail2ban` and `auditd`/`audit` using the detected host package manager - supported package-manager families: apt, dnf, yum, zypper, apk and pacman - enable, disable, restart and (where meaningful) reload the corresponding host services - current package/service/config-drift information in the UI diff --git a/internal/hostsecurity/firewall_provider.go b/internal/hostsecurity/firewall_provider.go new file mode 100644 index 0000000..4bee786 --- /dev/null +++ b/internal/hostsecurity/firewall_provider.go @@ -0,0 +1,890 @@ +package hostsecurity + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" +) + +const ( + FirewallProviderAuto = "auto" + FirewallProviderNftables = "nftables" + FirewallProviderUFW = "ufw" + FirewallProviderFirewalld = "firewalld" +) + +type FirewallBackendInfo struct { + Requested string `json:"requested"` + Selected string `json:"selected"` + Available []string `json:"available"` + Active []string `json:"active"` + Conflicts []string `json:"conflicts"` + DefaultZone string `json:"default_zone,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type FirewallExistingRule struct { + Provider string `json:"provider"` + Managed bool `json:"managed"` + Raw string `json:"raw"` +} + +type FirewallRuntimeView struct { + Provider string `json:"provider"` + Active bool `json:"active"` + DefaultInbound string `json:"default_inbound,omitempty"` + Zone string `json:"zone,omitempty"` + Rules []FirewallExistingRule `json:"rules"` + Raw string `json:"raw,omitempty"` +} + +type FirewallRuntimeSnapshot struct { + Provider string `json:"provider,omitempty"` + DefaultInbound string `json:"default_inbound,omitempty"` + Zone string `json:"zone,omitempty"` + ZoneTarget string `json:"zone_target,omitempty"` + FrontendActive bool `json:"frontend_active,omitempty"` + FrontendEnabled bool `json:"frontend_enabled,omitempty"` +} + +func normalizeFirewallProvider(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "" { + return FirewallProviderAuto + } + if oneOf(v, FirewallProviderAuto, FirewallProviderNftables, FirewallProviderUFW, FirewallProviderFirewalld) { + return v + } + return "" +} + +func (s *Service) firewallBackend(ctx context.Context, requested string) FirewallBackendInfo { + requested = normalizeFirewallProvider(requested) + if requested == "" { + requested = FirewallProviderAuto + } + b := FirewallBackendInfo{Requested: requested, Available: []string{}, Active: []string{}, Conflicts: []string{}} + if fileExists(s.hostPath("/usr/sbin/ufw")) || fileExists(s.hostPath("/usr/bin/ufw")) { + b.Available = append(b.Available, FirewallProviderUFW) + } + if fileExists(s.hostPath("/usr/bin/firewall-cmd")) || fileExists(s.hostPath("/bin/firewall-cmd")) { + b.Available = append(b.Available, FirewallProviderFirewalld) + } + if fileExists(s.hostPath("/usr/sbin/nft")) || fileExists(s.hostPath("/sbin/nft")) || fileExists(s.hostPath("/usr/bin/nft")) { + b.Available = append(b.Available, FirewallProviderNftables) + } + if s.capabilitiesNoCommand().TargetVerified { + x, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + if containsString(b.Available, FirewallProviderUFW) { + if out, err := s.hostCommand(x, nil, "ufw", "status"); err == nil && strings.Contains(strings.ToLower(out), "status: active") { + b.Active = append(b.Active, FirewallProviderUFW) + } + } + if containsString(b.Available, FirewallProviderFirewalld) { + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--state"); err == nil { + b.Active = append(b.Active, FirewallProviderFirewalld) + if z, err := s.hostCommand(x, nil, "firewall-cmd", "--get-default-zone"); err == nil { + b.DefaultZone = strings.TrimSpace(z) + } + } + } + if containsString(b.Available, FirewallProviderNftables) { + if _, err := s.hostCommand(x, nil, "nft", "list", "table", "inet", "dockwatch"); err == nil { + b.Active = append(b.Active, FirewallProviderNftables) + } + } + } + + if len(b.Active) > 1 { + b.Conflicts = append(b.Conflicts, b.Active...) + b.Reason = "multiple firewall frontends are active" + if requested == FirewallProviderAuto { + b.Selected = b.Active[0] + return b + } + } + if requested != FirewallProviderAuto { + b.Selected = requested + if !containsString(b.Available, requested) { + b.Reason = requested + " is not installed" + } + for _, active := range b.Active { + if active != requested { + b.Conflicts = uniqueStrings(append(b.Conflicts, active)) + } + } + if len(b.Conflicts) > 0 && b.Reason == "" { + b.Reason = "another firewall frontend is active" + } + return b + } + + if len(b.Active) == 1 { + b.Selected = b.Active[0] + return b + } + if containsString(b.Available, FirewallProviderNftables) { + b.Selected = FirewallProviderNftables + return b + } + if containsString(b.Available, FirewallProviderUFW) { + b.Selected = FirewallProviderUFW + return b + } + if containsString(b.Available, FirewallProviderFirewalld) { + b.Selected = FirewallProviderFirewalld + return b + } + b.Selected = FirewallProviderNftables + b.Reason = "no supported firewall frontend is installed" + return b +} + +func containsString(xs []string, v string) bool { + for _, x := range xs { + if x == v { + return true + } + } + return false +} + +func uniqueStrings(xs []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(xs)) + for _, x := range xs { + if x == "" || seen[x] { + continue + } + seen[x] = true + out = append(out, x) + } + sort.Strings(out) + return out +} + +func (s *Service) firewallRuntime(ctx context.Context, requested string) FirewallRuntimeView { + b := s.firewallBackend(ctx, requested) + v := FirewallRuntimeView{Provider: b.Selected, Rules: []FirewallExistingRule{}} + if !s.capabilitiesNoCommand().TargetVerified { + return v + } + x, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + switch b.Selected { + case FirewallProviderUFW: + out, err := s.hostCommand(x, nil, "ufw", "status", "numbered") + if err == nil { + v.Raw = out + v.Active = strings.Contains(strings.ToLower(out), "status: active") + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "[") { + continue + } + v.Rules = append(v.Rules, FirewallExistingRule{Provider: b.Selected, Managed: strings.Contains(strings.ToLower(line), "dockwatch:"), Raw: line}) + } + } + if verbose, err := s.hostCommand(x, nil, "ufw", "status", "verbose"); err == nil { + v.DefaultInbound = parseUFWDefaultInbound(verbose) + if v.Raw == "" { + v.Raw = verbose + } + } + if v.DefaultInbound == "" { + v.DefaultInbound = s.ufwDefaultFromConfig() + } + case FirewallProviderFirewalld: + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--state"); err == nil { + v.Active = true + } + zone := b.DefaultZone + if zone == "" { + zone = "public" + } + v.Zone = zone + if out, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--list-all"); err == nil { + v.Raw = out + } + if rules, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--list-rich-rules"); err == nil { + for _, line := range strings.Split(rules, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + v.Rules = append(v.Rules, FirewallExistingRule{Provider: b.Selected, Managed: false, Raw: line}) + } + } + if target, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--get-target"); err == nil { + target = strings.ToLower(strings.TrimSpace(target)) + if target == "drop" { + v.DefaultInbound = "drop" + } else if target == "accept" { + v.DefaultInbound = "accept" + } + } + case FirewallProviderNftables: + if out, err := s.hostCommand(x, nil, "nft", "list", "table", "inet", "dockwatch"); err == nil { + v.Active = true + v.Raw = out + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "dockwatch") && !strings.HasPrefix(line, "table ") { + v.Rules = append(v.Rules, FirewallExistingRule{Provider: b.Selected, Managed: true, Raw: line}) + } + } + if strings.Contains(out, "policy drop") { + v.DefaultInbound = "drop" + } else if strings.Contains(out, "policy accept") { + v.DefaultInbound = "accept" + } + } + } + return v +} + +func parseUFWDefaultInbound(out string) string { + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(strings.TrimSpace(line)) + if !strings.HasPrefix(l, "default:") { + continue + } + if strings.Contains(l, "deny (incoming)") || strings.Contains(l, "reject (incoming)") { + return "drop" + } + if strings.Contains(l, "allow (incoming)") { + return "accept" + } + } + return "" +} + +func (s *Service) ufwDefaultFromConfig() string { + b, err := os.ReadFile(s.hostPath("/etc/default/ufw")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(b), "\n") { + k, v, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || k != "DEFAULT_INPUT_POLICY" { + continue + } + v = strings.ToUpper(strings.Trim(strings.TrimSpace(v), `"'`)) + switch v { + case "DROP", "REJECT": + return "drop" + case "ACCEPT": + return "accept" + } + } + return "" +} + +func (s *Service) snapshotFirewallRuntime(ctx context.Context, provider, zone string) FirewallRuntimeSnapshot { + v := s.firewallRuntime(ctx, provider) + snap := FirewallRuntimeSnapshot{Provider: v.Provider, DefaultInbound: v.DefaultInbound, Zone: v.Zone, FrontendActive: v.Active} + if provider == FirewallProviderUFW { + snap.FrontendEnabled = v.Active + } + if provider == FirewallProviderFirewalld && s.capabilitiesNoCommand().TargetVerified { + _, snap.FrontendEnabled = s.serviceState(ctx, s.initSystem(), "firewalld") + if zone == "" { + zone = v.Zone + } + if zone != "" { + x, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + if out, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--get-target"); err == nil { + snap.Zone = zone + snap.ZoneTarget = strings.TrimSpace(out) + } + } + } + return snap +} + +func (s *Service) firewallComponentStatus(ctx context.Context, initSystem string, b FirewallBackendInfo) ComponentStatus { + st := ComponentStatus{Name: "firewall", Detail: "Provider: " + b.Selected} + switch b.Selected { + case FirewallProviderUFW: + st.Installed = fileExists(s.hostPath("/usr/sbin/ufw")) || fileExists(s.hostPath("/usr/bin/ufw")) + st.Service = "ufw" + case FirewallProviderFirewalld: + st.Installed = fileExists(s.hostPath("/usr/bin/firewall-cmd")) || fileExists(s.hostPath("/bin/firewall-cmd")) + st.Service = "firewalld" + case FirewallProviderNftables: + st.Installed = fileExists(s.hostPath("/usr/sbin/nft")) || fileExists(s.hostPath("/sbin/nft")) || fileExists(s.hostPath("/usr/bin/nft")) + st.Service = "dockwatch-firewall" + st.ConfigPath = firewallHostPath + } + if !st.Installed || !s.capabilitiesNoCommand().TargetVerified { + return st + } + x, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + switch b.Selected { + case FirewallProviderUFW: + if out, err := s.hostCommand(x, nil, "ufw", "--version"); err == nil { + st.Version = firstLine(out) + } + if out, err := s.hostCommand(x, nil, "ufw", "status"); err == nil { + st.Active = strings.Contains(strings.ToLower(out), "status: active") + st.Enabled = st.Active + } + case FirewallProviderFirewalld: + if out, err := s.hostCommand(x, nil, "firewall-cmd", "--version"); err == nil { + st.Version = firstLine(out) + } + st.Active, st.Enabled = s.serviceState(ctx, initSystem, "firewalld") + case FirewallProviderNftables: + if out, err := s.hostCommand(x, nil, "nft", "--version"); err == nil { + st.Version = firstLine(out) + } + if _, err := s.hostCommand(x, nil, "nft", "list", "table", "inet", "dockwatch"); err == nil { + st.Active = true + } + st.Enabled = s.persistenceEnabled(ctx, initSystem, "dockwatch-firewall") + } + return st +} + +func (s *Service) firewallManagedConfig(ctx context.Context, p FirewallPolicy, b FirewallBackendInfo) ManagedConfig { + if !p.Enabled { + return ManagedConfig{Configured: true, Path: "native:" + b.Selected, Hash: hashString(canonicalFirewallPolicy(p))} + } + if b.Selected == FirewallProviderNftables { + rendered, err := renderFirewall(p) + if err != nil { + return ManagedConfig{Configured: true, Path: firewallHostPath, Drift: true} + } + return s.managedConfig(firewallHostPath, rendered) + } + m := ManagedConfig{Configured: true, Path: "native:" + b.Selected, Hash: hashString(canonicalFirewallPolicy(p))} + v := s.firewallRuntime(ctx, b.Selected) + if b.Selected == FirewallProviderUFW { + raw := strings.ToLower(v.Raw) + for _, cidr := range p.TrustedCIDRs { + if !strings.Contains(raw, strings.ToLower(ufwTrustedTag(cidr))) { + m.Drift = true + break + } + } + if !m.Drift { + for _, r := range p.Rules { + if !strings.Contains(raw, strings.ToLower(ufwRuleTag(r))) { + m.Drift = true + break + } + } + } + if p.ManageDefault && v.DefaultInbound != "" && v.DefaultInbound != p.DefaultInbound { + m.Drift = true + } + return m + } + if b.Selected == FirewallProviderFirewalld { + zone := p.Zone + if zone == "" { + zone = v.Zone + } + desired := firewalldRichRules(p, zone) + actual := map[string]bool{} + for _, r := range v.Rules { + actual[strings.TrimSpace(r.Raw)] = true + } + for _, r := range desired { + if !actual[r] { + m.Drift = true + break + } + } + return m + } + return m +} + +func canonicalFirewallPolicy(p FirewallPolicy) string { + b := strings.Builder{} + fmt.Fprintf(&b, "%s|%s|%t|%t|%s|%s|%t|", normalizeFirewallProvider(p.Provider), p.ResolvedProvider, p.Enabled, p.ManageDefault, p.DefaultInbound, p.Zone, p.AllowICMP) + for _, x := range p.TrustedCIDRs { + b.WriteString(x + ";") + } + for _, r := range p.Rules { + fmt.Fprintf(&b, "%s,%s,%s,%s,%s;", r.Action, r.Protocol, r.Port, r.Source, r.Comment) + } + return b.String() +} + +func renderFirewallPlan(p FirewallPolicy, backend FirewallBackendInfo, runtime FirewallRuntimeView) (string, []string, error) { + if _, err := renderFirewall(p); err != nil { + return "", nil, err + } + warnings := []string{} + switch backend.Selected { + case FirewallProviderNftables: + rendered, err := renderFirewall(p) + if err != nil { + return "", nil, err + } + warnings = append(warnings, + "Native nftables mode manages only table inet dockwatch and never flushes the global ruleset.", + "Docker forwarding/NAT chains are not changed; the managed chain hooks host INPUT only.") + return rendered, warnings, nil + case FirewallProviderUFW: + var lines []string + lines = append(lines, "# Provider: UFW (native commands; foreign UFW rules are preserved)") + lines = append(lines, "# Dockwatch removes only rules carrying the dockwatch: comment before replacing its own rules.") + if p.ManageDefault { + verb := "allow" + if p.DefaultInbound == "drop" { + verb = "deny" + } + lines = append(lines, "ufw default "+verb+" incoming") + } + if p.Enabled { + for _, cidr := range p.TrustedCIDRs { + lines = append(lines, "ufw allow from "+cidr+" comment "+ufwTrustedTag(cidr)) + } + for _, r := range p.Rules { + lines = append(lines, strings.Join(ufwRuleArgs(r), " ")) + } + } else { + lines = append(lines, "# Dockwatch-managed UFW rules will be removed; UFW itself remains enabled/disabled as-is.") + } + warnings = append(warnings, "UFW provider preserves non-Dockwatch UFW rules. Apply never runs 'ufw reset'.") + warnings = append(warnings, "ICMP is left to UFW's native before.rules policy; the common Allow ICMP toggle is informational in UFW mode.") + return strings.Join(lines, "\n") + "\n", warnings, nil + case FirewallProviderFirewalld: + zone := p.Zone + if zone == "" { + zone = runtime.Zone + } + if zone == "" { + zone = backend.DefaultZone + } + if zone == "" { + zone = "public" + } + var lines []string + lines = append(lines, "# Provider: firewalld · zone "+zone) + lines = append(lines, "# Dockwatch manages only the rich rules represented by this policy; other zone rules remain untouched.") + if p.ManageDefault { + target := "ACCEPT" + if p.DefaultInbound == "drop" { + target = "DROP" + } + lines = append(lines, "firewall-cmd --zone="+zone+" --set-target="+target) + lines = append(lines, "firewall-cmd --permanent --zone="+zone+" --set-target="+target) + } + if p.Enabled { + for _, rule := range firewalldRichRules(p, zone) { + lines = append(lines, "firewall-cmd --zone="+zone+" --add-rich-rule="+strconv.Quote(rule)) + lines = append(lines, "firewall-cmd --permanent --zone="+zone+" --add-rich-rule="+strconv.Quote(rule)) + } + } else { + lines = append(lines, "# Previously Dockwatch-managed rich rules will be removed; the zone and firewalld daemon remain otherwise untouched.") + } + warnings = append(warnings, "firewalld provider preserves services, ports, sources and unrelated rich rules in the selected zone.") + warnings = append(warnings, "ICMP handling remains native to firewalld; Dockwatch does not add broad ICMP blocks in this common policy.") + return strings.Join(lines, "\n") + "\n", warnings, nil + default: + return "", nil, errors.New("unsupported firewall provider") + } +} + +func ufwRuleArgs(r FirewallRule) []string { + action := map[string]string{"accept": "allow", "drop": "deny", "reject": "reject", "limit": "limit"}[r.Action] + if action == "" { + action = "allow" + } + port := strings.ReplaceAll(r.Port, "-", ":") + args := []string{"ufw", action, "proto", r.Protocol, "from"} + if r.Source == "" { + args = append(args, "any") + } else { + args = append(args, r.Source) + } + args = append(args, "to", "any", "port", port, "comment", ufwRuleTag(r)) + return args +} + +func ufwRuleTag(r FirewallRule) string { + h := hashString(strings.Join([]string{r.Action, r.Protocol, r.Port, r.Source, r.Comment}, "|")) + if len(h) > 10 { + h = h[:10] + } + return "dockwatch:rule:" + h + ":" + sanitizeFirewallComment(r.Comment) +} +func ufwTrustedTag(cidr string) string { + h := hashString(cidr) + if len(h) > 10 { + h = h[:10] + } + return "dockwatch:trusted:" + h +} + +func sanitizeFirewallComment(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "rule" + } + v = strings.ReplaceAll(v, "'", "") + v = strings.ReplaceAll(v, `"`, "") + return v +} + +func firewalldRichRules(p FirewallPolicy, zone string) []string { + var out []string + for _, cidr := range p.TrustedCIDRs { + family := "ipv4" + if strings.Contains(cidr, ":") { + family = "ipv6" + } + out = append(out, fmt.Sprintf(`rule family="%s" source address="%s" accept`, family, cidr)) + } + for _, r := range p.Rules { + parts := []string{"rule"} + if r.Source != "" { + family := "ipv4" + if strings.Contains(r.Source, ":") { + family = "ipv6" + } + parts = append(parts, `family="`+family+`"`, `source address="`+r.Source+`"`) + } + parts = append(parts, `port port="`+r.Port+`" protocol="`+r.Protocol+`"`) + switch r.Action { + case "drop": + parts = append(parts, "drop") + case "reject": + parts = append(parts, "reject") + case "limit": + parts = append(parts, `accept limit value="6/m"`) + default: + parts = append(parts, "accept") + } + out = append(out, strings.Join(parts, " ")) + } + return out +} + +func (s *Service) applyFirewallProvider(ctx context.Context, p FirewallPolicy, current *FirewallPolicy, backend FirewallBackendInfo) error { + switch backend.Selected { + case FirewallProviderNftables: + rendered, err := renderFirewall(p) + if err != nil { + return err + } + if p.Enabled { + if err := s.validateFirewallRuntime(ctx, rendered); err != nil { + return err + } + } + if err := s.configureFirewallPersistence(ctx, p.Enabled, rendered); err != nil { + return err + } + return s.applyFirewallRuntime(ctx, p, rendered) + case FirewallProviderUFW: + return s.applyUFW(ctx, p) + case FirewallProviderFirewalld: + return s.applyFirewalld(ctx, p, current) + default: + return errors.New("unsupported firewall provider") + } +} + +func (s *Service) applyUFW(ctx context.Context, p FirewallPolicy) error { + if !fileExists(s.hostPath("/usr/sbin/ufw")) && !fileExists(s.hostPath("/usr/bin/ufw")) { + return errors.New("ufw is not installed") + } + x, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + wasActive := false + if out, err := s.hostCommand(x, nil, "ufw", "status"); err == nil { + wasActive = strings.Contains(strings.ToLower(out), "status: active") + } + if err := s.removeUFWManaged(x); err != nil { + return err + } + if p.ManageDefault { + verb := "allow" + if p.DefaultInbound == "drop" { + verb = "deny" + } + if _, err := s.hostCommand(x, nil, "ufw", "default", verb, "incoming"); err != nil { + return fmt.Errorf("set UFW default incoming: %w", err) + } + } + if !p.Enabled { + return nil + } + for _, cidr := range p.TrustedCIDRs { + if _, err := s.hostCommand(x, nil, "ufw", "allow", "from", cidr, "comment", ufwTrustedTag(cidr)); err != nil { + return fmt.Errorf("add UFW trusted CIDR %s: %w", cidr, err) + } + } + for _, r := range p.Rules { + if _, err := s.hostCommand(x, nil, ufwRuleArgs(r)...); err != nil { + return fmt.Errorf("add UFW rule %s/%s: %w", r.Port, r.Protocol, err) + } + } + if p.Enabled && !wasActive { + if _, err := s.hostCommand(x, nil, "ufw", "--force", "enable"); err != nil { + return fmt.Errorf("enable UFW after applying managed rules: %w", err) + } + } + return nil +} + +func (s *Service) removeUFWManaged(ctx context.Context) error { + out, err := s.hostCommand(ctx, nil, "ufw", "status", "numbered") + if err != nil { + return err + } + var nums []int + for _, line := range strings.Split(out, "\n") { + if !strings.Contains(strings.ToLower(line), "dockwatch:") { + continue + } + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "[") { + continue + } + end := strings.Index(line, "]") + if end < 0 { + continue + } + n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line[:end], "["))) + if err == nil { + nums = append(nums, n) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(nums))) + for _, n := range nums { + if _, err := s.hostCommand(ctx, nil, "ufw", "--force", "delete", strconv.Itoa(n)); err != nil { + return fmt.Errorf("delete Dockwatch UFW rule %d: %w", n, err) + } + } + return nil +} + +func (s *Service) applyFirewalld(ctx context.Context, p FirewallPolicy, current *FirewallPolicy) error { + if !fileExists(s.hostPath("/usr/bin/firewall-cmd")) && !fileExists(s.hostPath("/bin/firewall-cmd")) { + return errors.New("firewalld is not installed") + } + x, cancel := context.WithTimeout(ctx, 35*time.Second) + defer cancel() + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--state"); err != nil { + if !p.Enabled { + return errors.New("firewalld is inactive; enable/start it before removing Dockwatch-managed firewalld rules") + } + if _, startErr := s.serviceActionForName(ctx, "firewalld", "enable"); startErr != nil { + return fmt.Errorf("start firewalld before applying policy: %w", startErr) + } + } + if current != nil && s.providerForPolicy(ctx, *current) == FirewallProviderFirewalld { + oldZone := current.Zone + if oldZone == "" { + oldZone = s.firewallBackend(ctx, FirewallProviderFirewalld).DefaultZone + } + if oldZone == "" { + oldZone = "public" + } + for _, rule := range firewalldRichRules(*current, oldZone) { + _, _ = s.hostCommand(x, nil, "firewall-cmd", "--zone="+oldZone, "--remove-rich-rule="+rule) + _, _ = s.hostCommand(x, nil, "firewall-cmd", "--permanent", "--zone="+oldZone, "--remove-rich-rule="+rule) + } + } + zone := p.Zone + if zone == "" { + zone = s.firewallBackend(ctx, FirewallProviderFirewalld).DefaultZone + } + if zone == "" { + zone = "public" + } + if p.ManageDefault { + target := "ACCEPT" + if p.DefaultInbound == "drop" { + target = "DROP" + } + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--set-target="+target); err != nil { + return fmt.Errorf("set firewalld runtime target: %w", err) + } + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--permanent", "--zone="+zone, "--set-target="+target); err != nil { + return fmt.Errorf("set firewalld permanent target: %w", err) + } + } + if !p.Enabled { + return nil + } + for _, rule := range firewalldRichRules(p, zone) { + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+zone, "--add-rich-rule="+rule); err != nil { + return fmt.Errorf("add firewalld runtime rich rule: %w", err) + } + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--permanent", "--zone="+zone, "--add-rich-rule="+rule); err != nil { + return fmt.Errorf("add firewalld permanent rich rule: %w", err) + } + } + return nil +} + +func (s *Service) providerForPolicy(ctx context.Context, p FirewallPolicy) string { + if oneOf(p.ResolvedProvider, FirewallProviderUFW, FirewallProviderFirewalld, FirewallProviderNftables) { + return p.ResolvedProvider + } + provider := normalizeFirewallProvider(p.Provider) + if provider == FirewallProviderAuto || provider == "" { + provider = s.firewallBackend(ctx, FirewallProviderAuto).Selected + } + return provider +} + +func (s *Service) removeCurrentManagedFirewall(ctx context.Context, p FirewallPolicy) error { + provider := s.providerForPolicy(ctx, p) + disabled := p + disabled.Enabled = false + disabled.ManageDefault = false + b := s.firewallBackend(ctx, provider) + return s.applyFirewallProvider(ctx, disabled, &p, b) +} + +func (s *Service) cleanupAttemptedFirewall(ctx context.Context, p FirewallPolicy, backend FirewallBackendInfo) { + disabled := p + disabled.Enabled = false + disabled.ManageDefault = false + switch backend.Selected { + case FirewallProviderUFW: + x, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + _ = s.removeUFWManaged(x) + case FirewallProviderFirewalld: + _ = s.applyFirewalld(ctx, disabled, &p) + case FirewallProviderNftables: + _ = s.configureFirewallPersistence(ctx, false, "") + _ = s.applyFirewallRuntime(ctx, disabled, "") + } +} + +func (s *Service) restoreFirewallSnapshot(ctx context.Context, snap FirewallRuntimeSnapshot) error { + if snap.Provider == "" { + return nil + } + x, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + switch snap.Provider { + case FirewallProviderUFW: + if snap.DefaultInbound != "" { + verb := "allow" + if snap.DefaultInbound == "drop" { + verb = "deny" + } + if _, err := s.hostCommand(x, nil, "ufw", "default", verb, "incoming"); err != nil { + return err + } + } + if !snap.FrontendActive { + _, _ = s.hostCommand(x, nil, "ufw", "disable") + } + case FirewallProviderFirewalld: + if snap.Zone != "" && snap.ZoneTarget != "" { + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--zone="+snap.Zone, "--set-target="+snap.ZoneTarget); err != nil { + return err + } + if _, err := s.hostCommand(x, nil, "firewall-cmd", "--permanent", "--zone="+snap.Zone, "--set-target="+snap.ZoneTarget); err != nil { + return err + } + } + if !snap.FrontendActive { + if snap.FrontendEnabled { + _, _ = s.serviceActionForName(ctx, "firewalld", "stop") + } else { + _, _ = s.serviceActionForName(ctx, "firewalld", "disable") + } + } + } + return nil +} + +func (s *Service) firewallPackage(provider string) (pkg, service string, err error) { + provider = normalizeFirewallProvider(provider) + if provider == FirewallProviderAuto || provider == "" { + provider = s.firewallBackend(context.Background(), FirewallProviderAuto).Selected + if provider == "" { + provider = FirewallProviderNftables + } + } + switch provider { + case FirewallProviderUFW: + return "ufw", "", nil + case FirewallProviderFirewalld: + return "firewalld", "firewalld", nil + case FirewallProviderNftables: + return "nftables", "", nil + default: + return "", "", errors.New("unsupported firewall provider") + } +} + +func (s *Service) firewallServiceAction(ctx context.Context, action string) (string, error) { + p := s.FirewallPolicy() + provider := s.providerForPolicy(ctx, p) + if provider == "" { + return "", errors.New("no firewall provider selected") + } + x, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + switch provider { + case FirewallProviderUFW: + switch action { + case "enable", "start": + return s.hostCommand(x, nil, "ufw", "--force", "enable") + case "disable", "stop": + return s.hostCommand(x, nil, "ufw", "disable") + case "reload", "restart": + return s.hostCommand(x, nil, "ufw", "reload") + } + case FirewallProviderFirewalld: + return s.serviceActionForName(ctx, "firewalld", action) + case FirewallProviderNftables: + return s.serviceActionForName(ctx, "dockwatch-firewall", action) + } + return "", errors.New("unsupported firewall action") +} + +func (s *Service) serviceActionForName(ctx context.Context, service, action string) (string, error) { + initSystem := s.initSystem() + x, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + switch initSystem { + case "systemd": + args := []string{"systemctl"} + switch action { + case "enable": + args = append(args, "enable", "--now", service) + case "disable": + args = append(args, "disable", "--now", service) + default: + args = append(args, action, service) + } + return s.hostCommand(x, nil, args...) + case "openrc": + switch action { + case "enable": + if _, err := s.hostCommand(x, nil, "rc-update", "add", service, "default"); err != nil { + return "", err + } + return s.hostCommand(x, nil, "rc-service", service, "start") + case "disable": + _, _ = s.hostCommand(x, nil, "rc-service", service, "stop") + return s.hostCommand(x, nil, "rc-update", "del", service, "default") + default: + return s.hostCommand(x, nil, "rc-service", service, action) + } + } + return "", errors.New("unsupported init system") +} diff --git a/internal/hostsecurity/security.go b/internal/hostsecurity/security.go index b26ece3..2ff3e47 100644 --- a/internal/hostsecurity/security.go +++ b/internal/hostsecurity/security.go @@ -84,17 +84,18 @@ type Finding struct { } type Status struct { - Capabilities Capabilities `json:"capabilities"` - OS OSInfo `json:"os"` - PackageManager string `json:"package_manager,omitempty"` - InitSystem string `json:"init_system,omitempty"` - Firewall ComponentStatus `json:"firewall"` - Fail2Ban ComponentStatus `json:"fail2ban"` - Auditd ComponentStatus `json:"auditd"` - Conflicts []string `json:"firewall_conflicts"` - Findings []Finding `json:"findings"` - Score int `json:"score"` - Managed map[string]ManagedConfig `json:"managed"` + Capabilities Capabilities `json:"capabilities"` + OS OSInfo `json:"os"` + PackageManager string `json:"package_manager,omitempty"` + InitSystem string `json:"init_system,omitempty"` + Firewall ComponentStatus `json:"firewall"` + FirewallBackend FirewallBackendInfo `json:"firewall_backend"` + Fail2Ban ComponentStatus `json:"fail2ban"` + Auditd ComponentStatus `json:"auditd"` + Conflicts []string `json:"firewall_conflicts"` + Findings []Finding `json:"findings"` + Score int `json:"score"` + Managed map[string]ManagedConfig `json:"managed"` } type ManagedConfig struct { @@ -113,22 +114,28 @@ type FirewallRule struct { } type FirewallPolicy struct { - Enabled bool `json:"enabled"` - DefaultInbound string `json:"default_inbound"` - AllowICMP bool `json:"allow_icmp"` - TrustedCIDRs []string `json:"trusted_cidrs"` - Rules []FirewallRule `json:"rules"` + Provider string `json:"provider,omitempty"` + ResolvedProvider string `json:"resolved_provider,omitempty"` + Enabled bool `json:"enabled"` + ManageDefault bool `json:"manage_default"` + DefaultInbound string `json:"default_inbound"` + Zone string `json:"zone,omitempty"` + AllowICMP bool `json:"allow_icmp"` + TrustedCIDRs []string `json:"trusted_cidrs"` + Rules []FirewallRule `json:"rules"` } type FirewallPreview struct { - Policy FirewallPolicy `json:"policy"` - Rendered string `json:"rendered"` - Warnings []string `json:"warnings"` - Conflict []string `json:"conflicts"` - CanApply bool `json:"can_apply"` - Rollback bool `json:"rollback_supported"` - ManagedTable string `json:"managed_table"` - Persistence string `json:"persistence"` + Policy FirewallPolicy `json:"policy"` + Backend FirewallBackendInfo `json:"backend"` + Runtime FirewallRuntimeView `json:"runtime"` + Rendered string `json:"rendered"` + Warnings []string `json:"warnings"` + Conflict []string `json:"conflicts"` + CanApply bool `json:"can_apply"` + Rollback bool `json:"rollback_supported"` + ManagedTable string `json:"managed_table,omitempty"` + Persistence string `json:"persistence"` } type FirewallApplyResult struct { @@ -182,13 +189,16 @@ type PolicyResult struct { } type InstallInput struct { - Enable bool `json:"enable"` + Enable bool `json:"enable"` + Provider string `json:"provider,omitempty"` } type pendingFirewall struct { - ID string `json:"id"` - ExpiresAt int64 `json:"expires_at"` - Previous *FirewallPolicy `json:"previous,omitempty"` + ID string `json:"id"` + ExpiresAt int64 `json:"expires_at"` + Provider string `json:"provider,omitempty"` + Previous *FirewallPolicy `json:"previous,omitempty"` + Snapshot FirewallRuntimeSnapshot `json:"snapshot,omitempty"` } const ( @@ -221,26 +231,22 @@ func (s *Service) Status(ctx context.Context) Status { osInfo := s.osInfo() pm := s.packageManager() initSystem := s.initSystem() + fwPolicy := s.FirewallPolicy() + fwBackend := s.firewallBackend(ctx, fwPolicy.Provider) st := Status{ - Capabilities: caps, - OS: osInfo, - PackageManager: pm, - InitSystem: initSystem, - Firewall: s.componentStatus(ctx, "firewall", initSystem), - Fail2Ban: s.componentStatus(ctx, "fail2ban", initSystem), - Auditd: s.componentStatus(ctx, "auditd", initSystem), - Managed: map[string]ManagedConfig{}, + Capabilities: caps, + OS: osInfo, + PackageManager: pm, + InitSystem: initSystem, + Firewall: s.firewallComponentStatus(ctx, initSystem, fwBackend), + FirewallBackend: fwBackend, + Fail2Ban: s.componentStatus(ctx, "fail2ban", initSystem), + Auditd: s.componentStatus(ctx, "auditd", initSystem), + Managed: map[string]ManagedConfig{}, } - st.Conflicts = s.firewallConflicts(ctx) - if p, ok := s.loadFirewallPolicy(); ok { - rendered, _ := renderFirewall(p) - if p.Enabled { - st.Managed["firewall"] = s.managedConfig(firewallHostPath, rendered) - } else { - m := ManagedConfig{Configured: true, Path: firewallHostPath, Hash: hashString(rendered)} - m.Drift = fileExists(s.hostPath(firewallHostPath)) || st.Firewall.Active - st.Managed["firewall"] = m - } + st.Conflicts = append([]string(nil), fwBackend.Conflicts...) + if _, ok := s.loadFirewallPolicy(); ok { + st.Managed["firewall"] = s.firewallManagedConfig(ctx, fwPolicy, fwBackend) st.Firewall.Drift = st.Managed["firewall"].Drift } if p, ok := s.loadFail2BanPolicy(); ok { @@ -268,15 +274,19 @@ func findings(st Status) ([]Finding, int) { score -= 25 } if !st.Firewall.Installed { - fs = append(fs, Finding{Severity: "high", Title: "nftables not installed", Detail: "Dockwatch cannot manage its isolated inbound firewall table without nftables.", Action: "Install nftables"}) + provider := st.FirewallBackend.Selected + if provider == "" { + provider = "firewall" + } + fs = append(fs, Finding{Severity: "high", Title: "Firewall provider not installed", Detail: "Selected provider " + provider + " is not installed on this host.", Action: "Install " + provider}) score -= 25 } else if !st.Firewall.Active { - fs = append(fs, Finding{Severity: "medium", Title: "Dockwatch firewall policy not active", Detail: "The managed inet/dockwatch nftables table is not currently loaded."}) + fs = append(fs, Finding{Severity: "medium", Title: "Firewall frontend inactive", Detail: "Selected provider " + st.FirewallBackend.Selected + " is installed but not currently active."}) score -= 15 } if len(st.Conflicts) > 0 { - fs = append(fs, Finding{Severity: "medium", Title: "Another firewall frontend is active", Detail: "Dockwatch detected: " + strings.Join(st.Conflicts, ", ") + ". Managed firewall changes are blocked by default to avoid conflicting rule owners."}) - score -= 5 + fs = append(fs, Finding{Severity: "high", Title: "Competing firewall frontends detected", Detail: "Dockwatch detected conflicting active frontends: " + strings.Join(st.Conflicts, ", ") + ". Resolve this before applying changes."}) + score -= 10 } if !st.Fail2Ban.Installed { fs = append(fs, Finding{Severity: "medium", Title: "Fail2Ban not installed", Detail: "Brute-force protection is not available through Dockwatch.", Action: "Install Fail2Ban"}) @@ -580,47 +590,84 @@ func (s *Service) persistenceEnabled(ctx context.Context, initSystem, service st } func (s *Service) firewallConflicts(ctx context.Context) []string { - if !s.capabilitiesNoCommand().TargetVerified { - return nil - } - var out []string - x, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - if fileExists(s.hostPath("/usr/sbin/ufw")) || fileExists(s.hostPath("/usr/bin/ufw")) { - if v, err := s.hostCommand(x, nil, "ufw", "status"); err == nil && strings.Contains(strings.ToLower(v), "status: active") { - out = append(out, "ufw") - } - } - if fileExists(s.hostPath("/usr/bin/firewall-cmd")) { - if _, err := s.hostCommand(x, nil, "firewall-cmd", "--state"); err == nil { - out = append(out, "firewalld") - } - } - return out + return append([]string(nil), s.firewallBackend(ctx, FirewallProviderAuto).Conflicts...) } func (s *Service) FirewallPolicy() FirewallPolicy { if p, ok := s.loadFirewallPolicy(); ok { + if normalizeFirewallProvider(p.Provider) == "" { + p.Provider = FirewallProviderAuto + } + if p.DefaultInbound == "" { + p.DefaultInbound = "accept" + } + if p.TrustedCIDRs == nil { + p.TrustedCIDRs = []string{} + } + if p.Rules == nil { + p.Rules = []FirewallRule{} + } return p } - return FirewallPolicy{Enabled: false, DefaultInbound: "accept", AllowICMP: true, TrustedCIDRs: []string{}, Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Comment: "SSH"}}} + return FirewallPolicy{Provider: FirewallProviderAuto, Enabled: false, ManageDefault: false, DefaultInbound: "accept", AllowICMP: true, TrustedCIDRs: []string{}, Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Comment: "SSH"}}} } func (s *Service) PreviewFirewall(ctx context.Context, p FirewallPolicy) (FirewallPreview, error) { - rendered, err := renderFirewall(p) + provider := normalizeFirewallProvider(p.Provider) + if provider == "" { + return FirewallPreview{}, errors.New("provider must be auto, ufw, firewalld or nftables") + } + p.Provider = provider + if p.DefaultInbound == "" { + p.DefaultInbound = "accept" + } + if p.TrustedCIDRs == nil { + p.TrustedCIDRs = []string{} + } + if p.Rules == nil { + p.Rules = []FirewallRule{} + } + backend := s.firewallBackend(ctx, provider) + p.ResolvedProvider = backend.Selected + runtime := s.firewallRuntime(ctx, provider) + if backend.Selected == FirewallProviderFirewalld && p.Zone == "" { + p.Zone = runtime.Zone + if p.Zone == "" { + p.Zone = backend.DefaultZone + } + } + if backend.Selected == FirewallProviderFirewalld && p.Zone != "" && (!safeNameRE.MatchString(p.Zone) || strings.HasPrefix(p.Zone, "-")) { + return FirewallPreview{}, errors.New("firewalld zone name is invalid") + } + rendered, warnings, err := renderFirewallPlan(p, backend, runtime) if err != nil { return FirewallPreview{}, err } - conflicts := s.firewallConflicts(ctx) - warnings := []string{"Dockwatch manages only table inet dockwatch and never flushes the global nftables ruleset.", "Docker forwarding/NAT chains are not changed; this policy hooks only host INPUT traffic."} - if p.DefaultInbound == "drop" { + if p.DefaultInbound == "drop" && (backend.Selected == FirewallProviderNftables || p.ManageDefault) { warnings = append(warnings, "Default inbound DROP can lock out SSH or Dockwatch. Apply uses a timed rollback until explicitly committed.") } - if len(conflicts) > 0 { - warnings = append(warnings, "Another firewall frontend is active: "+strings.Join(conflicts, ", ")+". Apply is blocked to prevent multiple owners of host filtering policy.") + if len(backend.Conflicts) > 0 { + warnings = append(warnings, "Competing active firewall frontends detected: "+strings.Join(backend.Conflicts, ", ")+". Apply is blocked until only the selected frontend owns host filtering.") + } + if backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !runtime.Active { + warnings = append(warnings, "firewalld is inactive. Start it before changing the zone target so Dockwatch can snapshot the current runtime/permanent target for rollback.") + } + if oneOf(backend.Selected, FirewallProviderUFW, FirewallProviderFirewalld) && p.Enabled && !runtime.Active { + warnings = append(warnings, "The selected firewall frontend is inactive. Applying an enabled Dockwatch policy will activate it; rollback restores the previous active state.") + } + if !containsString(backend.Available, backend.Selected) { + warnings = append(warnings, backend.Selected+" is not installed on this host.") } caps := s.capabilities(ctx) - return FirewallPreview{Policy: p, Rendered: rendered, Warnings: warnings, Conflict: conflicts, CanApply: caps.AllowChanges && caps.ExecutorAvailable && caps.HostRootWritable && len(conflicts) == 0, Rollback: true, ManagedTable: "inet dockwatch", Persistence: s.initSystem()}, nil + managedTable := "" + if backend.Selected == FirewallProviderNftables { + managedTable = "inet dockwatch" + } + canApply := caps.AllowChanges && caps.ExecutorAvailable && caps.HostRootWritable && len(backend.Conflicts) == 0 && containsString(backend.Available, backend.Selected) + if backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !runtime.Active { + canApply = false + } + return FirewallPreview{Policy: p, Backend: backend, Runtime: runtime, Rendered: rendered, Warnings: warnings, Conflict: backend.Conflicts, CanApply: canApply, Rollback: true, ManagedTable: managedTable, Persistence: s.initSystem()}, nil } func renderFirewall(p FirewallPolicy) (string, error) { @@ -636,8 +683,11 @@ func renderFirewall(p FirewallPolicy) (string, error) { } } for i, r := range p.Rules { - if r.Action != "accept" && r.Action != "drop" { - return "", fmt.Errorf("rule %d action must be accept or drop", i+1) + if !oneOf(r.Action, "accept", "drop", "reject", "limit") { + return "", fmt.Errorf("rule %d action must be accept, drop, reject or limit", i+1) + } + if r.Action == "limit" && r.Protocol != "tcp" { + return "", fmt.Errorf("rule %d limit action is supported only for tcp", i+1) } if r.Protocol != "tcp" && r.Protocol != "udp" { return "", fmt.Errorf("rule %d protocol must be tcp or udp", i+1) @@ -687,7 +737,12 @@ func renderFirewall(p FirewallPolicy) (string, error) { comment = "dockwatch " + strings.TrimSpace(r.Comment) } comment = strings.ReplaceAll(comment, `"`, `'`) - b.WriteString(fmt.Sprintf(" %s%s dport %s %s comment %s\n", src, r.Protocol, r.Port, r.Action, strconv.Quote(comment))) + action := r.Action + if action == "limit" { + b.WriteString(fmt.Sprintf(" %s%s dport %s ct state new limit rate 6/minute accept comment %s\n", src, r.Protocol, r.Port, strconv.Quote(comment))) + continue + } + b.WriteString(fmt.Sprintf(" %s%s dport %s %s comment %s\n", src, r.Protocol, r.Port, action, strconv.Quote(comment))) } b.WriteString(" }\n}\n") return b.String(), nil @@ -715,16 +770,20 @@ func (s *Service) ApplyFirewall(ctx context.Context, p FirewallPolicy, rollbackS if err := s.requireChanges(ctx); err != nil { return FirewallApplyResult{}, err } - if len(s.firewallConflicts(ctx)) > 0 { - return FirewallApplyResult{}, errors.New("another firewall frontend is active (ufw/firewalld); Dockwatch refuses to apply a competing managed ruleset") - } - if !fileExists(s.hostPath("/usr/sbin/nft")) && !fileExists(s.hostPath("/sbin/nft")) { - return FirewallApplyResult{}, errors.New("nftables is not installed") - } preview, err := s.PreviewFirewall(ctx, p) if err != nil { return FirewallApplyResult{}, err } + p = preview.Policy + if len(preview.Backend.Conflicts) > 0 { + return FirewallApplyResult{}, fmt.Errorf("competing firewall frontend(s) active: %s", strings.Join(preview.Backend.Conflicts, ", ")) + } + if !containsString(preview.Backend.Available, preview.Backend.Selected) { + return FirewallApplyResult{}, fmt.Errorf("selected firewall provider %s is not installed", preview.Backend.Selected) + } + if preview.Backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !preview.Runtime.Active { + return FirewallApplyResult{}, errors.New("firewalld must be active before Dockwatch can change and safely roll back the zone target") + } if rollbackSeconds == 0 { rollbackSeconds = 90 } @@ -736,30 +795,34 @@ func (s *Service) ApplyFirewall(ctx context.Context, p FirewallPolicy, rollbackS if hadPrevious { previousPtr = &previous } - rollbackState := pendingFirewall{Previous: previousPtr} - if p.Enabled { - if err := s.validateFirewallRuntime(ctx, preview.Rendered); err != nil { - return FirewallApplyResult{}, err + snapshot := s.snapshotFirewallRuntime(ctx, preview.Backend.Selected, p.Zone) + rollbackState := pendingFirewall{Provider: preview.Backend.Selected, Previous: previousPtr, Snapshot: snapshot} + if hadPrevious { + oldProvider := s.providerForPolicy(ctx, previous) + if oldProvider != "" && oldProvider != preview.Backend.Selected { + if err := s.removeCurrentManagedFirewall(ctx, previous); err != nil { + return FirewallApplyResult{}, fmt.Errorf("remove previous %s managed firewall before provider switch: %w", oldProvider, err) + } } } - if err := s.configureFirewallPersistence(ctx, p.Enabled, preview.Rendered); err != nil { - _ = s.rollbackFirewallLocked(ctx, rollbackState) - return FirewallApplyResult{}, err - } - if err := s.applyFirewallRuntime(ctx, p, preview.Rendered); err != nil { + if err := s.applyFirewallProvider(ctx, p, previousPtr, preview.Backend); err != nil { + s.cleanupAttemptedFirewall(ctx, p, preview.Backend) _ = s.rollbackFirewallLocked(ctx, rollbackState) return FirewallApplyResult{}, err } if err := s.savePolicy("firewall.json", p); err != nil { + s.cleanupAttemptedFirewall(ctx, p, preview.Backend) + _ = s.rollbackFirewallLocked(ctx, rollbackState) return FirewallApplyResult{}, err } id := randomID() - pending := pendingFirewall{ID: id, ExpiresAt: time.Now().Add(time.Duration(rollbackSeconds) * time.Second).Unix(), Previous: previousPtr} + pending := pendingFirewall{ID: id, ExpiresAt: time.Now().Add(time.Duration(rollbackSeconds) * time.Second).Unix(), Provider: preview.Backend.Selected, Previous: previousPtr, Snapshot: snapshot} if err := s.savePendingFirewall(pending); err != nil { + _ = s.rollbackFirewallLocked(ctx, rollbackState) return FirewallApplyResult{}, err } s.scheduleFirewallRollback(pending) - return FirewallApplyResult{OK: true, ChangeID: id, ExpiresAt: pending.ExpiresAt, Preview: preview, Message: "Firewall applied temporarily. Commit the change before the rollback timer expires."}, nil + return FirewallApplyResult{OK: true, ChangeID: id, ExpiresAt: pending.ExpiresAt, Preview: preview, Message: "Firewall policy applied through " + preview.Backend.Selected + ". Commit the change before the rollback timer expires."}, nil } func (s *Service) CommitFirewall(id string) error { @@ -917,25 +980,43 @@ func (s *Service) scheduleFirewallRollback(p pendingFirewall) { } func (s *Service) rollbackFirewallLocked(ctx context.Context, p pendingFirewall) error { + current, hasCurrent := s.loadFirewallPolicy() if p.Previous == nil { - disabled := FirewallPolicy{Enabled: false, DefaultInbound: "accept", AllowICMP: true} - if err := s.configureFirewallPersistence(ctx, false, ""); err != nil { - return err + if hasCurrent { + if err := s.removeCurrentManagedFirewall(ctx, current); err != nil { + return err + } } - if err := s.applyFirewallRuntime(ctx, disabled, ""); err != nil { + if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil { return err } _ = os.Remove(s.securityPath("firewall.json")) return nil } - rendered, err := renderFirewall(*p.Previous) - if err != nil { - return err + provider := s.providerForPolicy(ctx, *p.Previous) + backend := s.firewallBackend(ctx, provider) + var currentPtr *FirewallPolicy + restoredSnapshot := false + if hasCurrent { + currentPtr = ¤t + currentProvider := s.providerForPolicy(ctx, current) + if currentProvider != "" && currentProvider != provider { + if err := s.removeCurrentManagedFirewall(ctx, current); err != nil { + return err + } + if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil { + return err + } + restoredSnapshot = true + currentPtr = nil + } } - if err := s.configureFirewallPersistence(ctx, p.Previous.Enabled, rendered); err != nil { - return err + if !restoredSnapshot && !p.Previous.ManageDefault && oneOf(p.Snapshot.Provider, FirewallProviderUFW, FirewallProviderFirewalld) { + if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil { + return err + } } - if err := s.applyFirewallRuntime(ctx, *p.Previous, rendered); err != nil { + if err := s.applyFirewallProvider(ctx, *p.Previous, currentPtr, backend); err != nil { return err } return s.savePolicy("firewall.json", *p.Previous) @@ -1185,7 +1266,13 @@ func (s *Service) Install(ctx context.Context, component string, in InstallInput if err := s.requirePackages(ctx); err != nil { return PolicyResult{}, err } - pkg, service, err := s.packageFor(component) + var pkg, service string + var err error + if component == "firewall" { + pkg, service, err = s.firewallPackage(in.Provider) + } else { + pkg, service, err = s.packageFor(component) + } if err != nil { return PolicyResult{}, err } @@ -1232,8 +1319,23 @@ func (s *Service) Install(ctx context.Context, component string, in InstallInput default: return PolicyResult{}, errors.New("unsupported package manager") } - if in.Enable && service != "" { - _, _ = s.serviceActionUnlocked(ctx, component, "enable") + if component == "firewall" { + // Persist an explicit provider selection even when installation does not + // activate the frontend. This keeps Auto from immediately switching back + // to another already-installed firewall after package installation. + p := s.FirewallPolicy() + if v := normalizeFirewallProvider(in.Provider); v != "" && v != FirewallProviderAuto { + p.Provider = v + p.ResolvedProvider = v + _ = s.savePolicy("firewall.json", p) + } + } + if in.Enable { + if component == "firewall" { + _, _ = s.firewallServiceAction(ctx, "enable") + } else if service != "" { + _, _ = s.serviceActionUnlocked(ctx, component, "enable") + } } return PolicyResult{OK: true, Message: component + " installed/updated.", Output: limit(out, 12000)}, nil } @@ -1258,7 +1360,10 @@ func (s *Service) serviceActionUnlocked(ctx context.Context, component, action s if !oneOf(action, "enable", "disable", "restart", "reload", "start", "stop") { return "", errors.New("unsupported service action") } - service := map[string]string{"firewall": "dockwatch-firewall", "fail2ban": "fail2ban", "auditd": "auditd"}[component] + if component == "firewall" { + return s.firewallServiceAction(ctx, action) + } + service := map[string]string{"fail2ban": "fail2ban", "auditd": "auditd"}[component] if component == "auditd" && action == "reload" { x, c := context.WithTimeout(ctx, 15*time.Second) defer c() @@ -1302,7 +1407,7 @@ func (s *Service) packageFor(component string) (pkg, service string, err error) osid := s.osInfo().ID switch component { case "firewall": - return "nftables", "", nil + return s.firewallPackage(FirewallProviderAuto) case "fail2ban": return "fail2ban", "fail2ban", nil case "auditd": diff --git a/internal/hostsecurity/security_test.go b/internal/hostsecurity/security_test.go index 012494d..ac85cc9 100644 --- a/internal/hostsecurity/security_test.go +++ b/internal/hostsecurity/security_test.go @@ -1,6 +1,8 @@ package hostsecurity import ( + "os" + "path/filepath" "strings" "testing" ) @@ -73,3 +75,119 @@ func TestAuditWatchValidation(t *testing.T) { } } } + +func TestFirewallProviderPlansPreserveForeignRules(t *testing.T) { + p := FirewallPolicy{ + Provider: FirewallProviderUFW, + Enabled: true, + ManageDefault: true, + DefaultInbound: "drop", + TrustedCIDRs: []string{"192.0.2.0/24"}, + Rules: []FirewallRule{ + {Action: "accept", Protocol: "tcp", Port: "22", Comment: "ssh"}, + {Action: "limit", Protocol: "tcp", Port: "443", Source: "198.51.100.0/24", Comment: "https"}, + }, + } + backend := FirewallBackendInfo{Selected: FirewallProviderUFW} + out, warnings, err := renderFirewallPlan(p, backend, FirewallRuntimeView{Provider: FirewallProviderUFW}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"ufw default deny incoming", "ufw allow from 192.0.2.0/24", "ufw allow proto tcp", "ufw limit proto tcp"} { + if !strings.Contains(out, want) { + t.Fatalf("UFW plan missing %q:\n%s", want, out) + } + } + if strings.Contains(strings.ToLower(out), "ufw reset") { + t.Fatal("UFW provider must never reset foreign rules") + } + if len(warnings) == 0 { + t.Fatal("expected provider safety warnings") + } +} + +func TestFirewalldPlanUsesNativeRichRules(t *testing.T) { + p := FirewallPolicy{ + Provider: FirewallProviderFirewalld, + Enabled: true, + ManageDefault: true, + DefaultInbound: "drop", + Zone: "public", + Rules: []FirewallRule{{Action: "reject", Protocol: "tcp", Port: "23", Source: "203.0.113.0/24"}}, + } + backend := FirewallBackendInfo{Selected: FirewallProviderFirewalld, DefaultZone: "public"} + out, _, err := renderFirewallPlan(p, backend, FirewallRuntimeView{Provider: FirewallProviderFirewalld, Zone: "public"}) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"firewall-cmd --zone=public --set-target=DROP", "--add-rich-rule", `source address=\"203.0.113.0/24\"`, "reject"} { + if !strings.Contains(out, want) { + t.Fatalf("firewalld plan missing %q:\n%s", want, out) + } + } + if strings.Contains(out, "--remove-service") || strings.Contains(out, "--remove-port") { + t.Fatal("preview must not suggest deleting foreign zone primitives") + } +} + +func TestFirewallProviderNormalization(t *testing.T) { + for in, want := range map[string]string{"": "auto", "AUTO": "auto", "ufw": "ufw", "Firewalld": "firewalld", "nftables": "nftables"} { + if got := normalizeFirewallProvider(in); got != want { + t.Fatalf("normalize %q = %q want %q", in, got, want) + } + } + if got := normalizeFirewallProvider("iptables"); got != "" { + t.Fatalf("unsupported provider should normalize to empty, got %q", got) + } +} + +func TestRenderNftablesSupportsRejectAndLimit(t *testing.T) { + p := FirewallPolicy{DefaultInbound: "accept", Rules: []FirewallRule{ + {Action: "reject", Protocol: "tcp", Port: "23"}, + {Action: "limit", Protocol: "tcp", Port: "22"}, + }} + out, err := renderFirewall(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "tcp dport 23 reject") { + t.Fatalf("reject rule missing:\n%s", out) + } + if !strings.Contains(out, "tcp dport 22 ct state new limit rate 6/minute accept") { + t.Fatalf("limit rule missing:\n%s", out) + } + bad := FirewallPolicy{Rules: []FirewallRule{{Action: "limit", Protocol: "udp", Port: "53"}}} + if _, err := renderFirewall(bad); err == nil { + t.Fatal("UDP limit rule should be rejected") + } +} + +func TestUFWDefaultFromConfigWhenInactive(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "etc", "default"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "etc", "default", "ufw"), []byte("DEFAULT_INPUT_POLICY=\"DROP\"\n"), 0o644); err != nil { + t.Fatal(err) + } + s := New(Config{Enabled: true, HostRoot: root, DataDir: t.TempDir()}) + if got := s.ufwDefaultFromConfig(); got != "drop" { + t.Fatalf("ufw default = %q want drop", got) + } +} + +func TestUFWRulesHaveDeterministicDockwatchOwnershipTags(t *testing.T) { + r := FirewallRule{Action: "accept", Protocol: "tcp", Port: "22", Source: "192.0.2.0/24", Comment: "ssh admin"} + a := ufwRuleArgs(r) + b := ufwRuleArgs(r) + if strings.Join(a, "|") != strings.Join(b, "|") { + t.Fatal("UFW rule tag must be deterministic") + } + joined := strings.Join(a, " ") + if !strings.Contains(joined, "dockwatch:rule:") { + t.Fatalf("missing Dockwatch ownership tag: %s", joined) + } + if strings.Contains(strings.ToLower(joined), "reset") { + t.Fatal("UFW rule command must not reset firewall") + } +} diff --git a/web/app.js b/web/app.js index a585d7b..16d8ff6 100644 --- a/web/app.js +++ b/web/app.js @@ -216,12 +216,13 @@ async function renderSecurity(){ try{ const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{}; window.__securityCaps=c; + window.__firewallBackend=d.firewall_backend||{}; const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit'; $('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'')}
${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}
${esc(desc)}
${esc(st.detail)}`:''}${conflicts?.length?`${esc(desc)}
${esc(st.detail)}`:''}${conflicts?.length?`${esc(f.detail)}
${f.action?`${esc(f.action)}`:''}table inet dockwatch and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.${esc(r.raw||'No runtime rules reported.')}ufw reset or nft flush ruleset.${esc(text||'')}Verify SSH and any other management path in a separate session. Then keep the change.
Verify SSH and any other management path in a separate session. Then keep the change.
/etc/fail2ban/jail.d/dockwatch.local. Other distro/user jails are preserved and remain effective.