From 6eb4e093ece75af5803bd340e2b14e91d5e9aa1b Mon Sep 17 00:00:00 2001 From: groot Date: Tue, 1 Sep 2026 13:59:25 +0200 Subject: [PATCH] Update --- Dockerfile | 2 +- README.md | 42 +- internal/hostsecurity/firewall_provider.go | 890 +++++++++++++++++++++ internal/hostsecurity/security.go | 325 +++++--- internal/hostsecurity/security_test.go | 118 +++ web/app.js | 21 +- web/index.html | 4 +- web/styles.css | 2 +- 8 files changed, 1267 insertions(+), 137 deletions(-) create mode 100644 internal/hostsecurity/firewall_provider.go 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()}`,'')}
HOST SECURITY POSTURE
${Number(d.score||0)}/100

${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}

${securityCapabilityPills(c)}
${c.reason?`
Capability: ${esc(c.reason)}
`:''}
- ${securityComponentCard('firewall','Firewall · nftables','Isolated host INPUT policy. Docker NAT/FORWARD chains remain untouched.',d.firewall,managed.firewall,d.conflicts)} + ${securityComponentCard('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Auto-detected native firewall management through UFW, firewalld or isolated nftables.',d.firewall,managed.firewall,d.conflicts)} ${securityComponentCard('fail2ban','Fail2Ban','Rate-limit and ban repeated authentication failures using managed jail.d overrides.',d.fail2ban,managed.fail2ban)} ${securityComponentCard('auditd','Linux Audit · auditd','Track changes to identity, SSH, sudo, Docker and selected host paths.',d.auditd,managed.auditd)}
@@ -236,17 +237,19 @@ async function renderSecurity(){ }catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}
${esc(e.message)}
`} } function securityCapabilityPills(c){return `host root ${c.host_root_available?'✓':'×'}host namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'manage':'audit only'}packages ${c.allow_package_management?'enabled':'locked'}`} -function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"';return `
${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

${esc(title)}

${esc(desc)}

${installed?(active?badge('up'):badge('paused')):badge('unknown')}
Installed${installed?'Yes':'No'}Runtime${active?'Active':'Inactive'}Boot${st.enabled?'Enabled':'—'}Config${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}
${st.version?`
${esc(st.version)}
`:''}${st.detail?`
${esc(st.detail)}
`:''}${conflicts?.length?`
Conflict: ${esc(conflicts.join(', '))} active. Dockwatch firewall apply is blocked.
`:''}
${!installed?``:''}${installed?`${key!=='firewall'?``:''}`:''}
`} +function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"',canConfigure=manage&&(key==='firewall'||installed);return `
${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

${title}

${esc(desc)}

${installed?(active?badge('up'):badge('paused')):badge('unknown')}
Installed${installed?'Yes':'No'}Runtime${active?'Active':'Inactive'}Boot${st.enabled?'Enabled':'—'}Config${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}
${st.version?`
${esc(st.version)}
`:''}${st.detail?`
${esc(st.detail)}
`:''}${conflicts?.length?`
Firewall conflict: ${esc(conflicts.join(', '))}. Resolve competing active frontends before applying changes.
`:''}
${!installed?``:''}${installed?`${key!=='firewall'?``:''}`:''}
`} function securityFindings(rows){if(!rows.length)return '
No findings.
';return `
${rows.map(f=>`
${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}
${esc(f.title)}

${esc(f.detail)}

${f.action?`${esc(f.action)}`:''}
`).join('')}
`} -async function securityInstall(component,btn){if(!confirm(`Install or upgrade ${component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall'})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}} +async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`Install or upgrade ${component==='firewall'?provider:component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}} async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}} -async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode());firewallEditor(p)}catch(e){toast(e.message)}} -function firewallEditor(p){const rules=asArray(p.rules);modal(`

Managed nftables firewall

Scope: Dockwatch manages only table inet dockwatch and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.

Port rules

${rules.map(firewallRuleRow).join('')}
Lockout protection: Apply starts a 90-second rollback timer. You must explicitly keep the rules after confirming that this UI is still reachable. Default DROP requires that you add the management ports/CIDRs you need.
`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview('nftables preview',x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy();if(policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}} -function firewallRuleRow(r={}){return `
`} -function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove())} -function collectFirewallPolicy(){return {enabled:$('#fwEnabled').checked,default_inbound:$('#fwDefault').value,allow_icmp:$('#fwICMP').checked,trusted_cidrs:$('#fwTrusted').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}} +async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}} +function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>``).join('')} +function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `

Detected host firewall

selected: ${esc(b.selected||'—')}
Available${esc(available.join(', ')||'none')}Active${esc(active.join(', ')||'none')}Default${esc(r.default_inbound||'—')}Zone${esc(r.zone||b.default_zone||'—')}
${b.reason?`
${esc(b.reason)}
`:''}
${esc(r.raw||'No runtime rules reported.')}
`} +function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`

Host firewall · ${esc(selected)}

Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?`
`:''}
${firewallRuntimeHTML(preview)}

Dockwatch-managed port rules

${rules.map(firewallRuleRow).join('')}
Lockout protection: Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.
${!installed&&selected!=='auto'?``:''}
`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}} +function firewallRuleRow(r={}){return `
`} +function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})} +function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}} function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`

${esc(title)}

${warnings.map(x=>`
${esc(x)}
`).join('')}${conflicts?.length?`
Conflicts: ${esc(conflicts.join(', '))}
`:''}
${esc(text||'')}
`)} -function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`

Firewall applied · verification window

Do not close this dialog yet. If the new policy breaks access, Dockwatch will restore the previous managed firewall policy automatically.
Automatic rollback in…

Verify SSH and any other management path in a separate session. Then keep the change.

`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}} +function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`

Firewall applied · verification window

Do not close this dialog yet. If the new policy breaks access, Dockwatch will restore the previous Dockwatch-managed policy and provider default snapshot automatically.
Automatic rollback in…

Verify SSH and any other management path in a separate session. Then keep the change.

`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}} async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}} function fail2banEditor(p){modal(`

Fail2Ban policy

Dockwatch writes only /etc/fail2ban/jail.d/dockwatch.local. Other distro/user jails are preserved and remain effective.

Jails

${asArray(p.jails).map(fail2banJailRow).join('')}
`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban applied');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}} function fail2banJailRow(j={}){return `
`} diff --git a/web/index.html b/web/index.html index ba3b7a9..881c542 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ Dockwatch - +
@@ -39,5 +39,5 @@
- + diff --git a/web/styles.css b/web/styles.css index d4d705e..5069c21 100644 --- a/web/styles.css +++ b/web/styles.css @@ -22,7 +22,7 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap .mono{font:11px/1.45 "Cascadia Code","SFMono-Regular",Consolas,monospace}.warn{color:var(--amber)}.compactList{margin:8px 0 0;padding-left:18px;color:var(--muted)}.compactList li{margin:6px 0}.panel.inset{padding:12px;background:var(--panel2);overflow:visible}.panel.inset h3{margin:0;font-size:12px}.dangerNotice{border-color:#592733!important;background:#2b171e!important;color:#f1bcc5!important}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:7px}.tablewrap .table{min-width:720px} /* Host Security layer */ -.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace} +.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px 28px 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.fwRuntime{margin-top:12px}.fwExisting{font-size:9px;line-height:1.45;white-space:pre-wrap}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace} @media(max-width:1300px){.securityGrid{grid-template-columns:1fr}.securityFacts{grid-template-columns:repeat(4,1fr)}} @media(max-width:860px){.securityHero{align-items:flex-start;flex-direction:column}.securityCaps{justify-content:flex-start}.securityFacts{grid-template-columns:repeat(2,1fr)}.securitySafety{grid-template-columns:1fr}.fwRule{grid-template-columns:1fr 1fr}.fwRule .fwPort,.fwRule .fwSource,.fwRule .fwComment{grid-column:span 2}.securityFormRow{grid-template-columns:1fr 1fr}.securityFormRow .f2bLog{grid-column:span 2}.securityFormRow.audit{grid-template-columns:1fr}.securityChecks{grid-template-columns:1fr}} .volume-reconcile{margin-bottom:14px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}