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
+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":