@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user