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") }