Update
release-tag / release-image (push) Successful in 2m45s

This commit is contained in:
2026-09-01 13:59:25 +02:00
parent c609c34f16
commit 6eb4e093ec
8 changed files with 1267 additions and 137 deletions
+890
View File
@@ -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")
}
+215 -110
View File
@@ -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 = &current
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":
+118
View File
@@ -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")
}
}