+1
-1
@@ -7,7 +7,7 @@ ARG BUILD_DATE=unknown
|
||||
COPY go.mod ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./internal/hostsecurity/security.go && test -f ./web/embed.go || \
|
||||
RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./internal/hostsecurity/security.go && test -f ./internal/hostsecurity/firewall_provider.go && test -f ./web/embed.go || \
|
||||
(echo "ERROR: required source files are missing from Docker build context; check .dockerignore" >&2; exit 1)
|
||||
# Keep the module graph in sync with the actual source tree. This is required for
|
||||
# Go 1.17+ module graph pruning when transitive dependencies must be recorded as
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Dockwatch v9.4.1
|
||||
# Dockwatch v9.5
|
||||
|
||||
> Go module: `git.send.nrw/sendnrw/dockwatch`
|
||||
|
||||
@@ -6,6 +6,19 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc
|
||||
|
||||
The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`.
|
||||
|
||||
## v9.5 firewall-provider layer
|
||||
|
||||
- Host Security firewall management now uses a provider model: **Auto detect / UFW / firewalld / native nftables**
|
||||
- Auto detect adopts an already-active UFW or firewalld frontend instead of refusing all changes simply because one exists
|
||||
- multiple truly competing active frontends are still treated as a hard conflict
|
||||
- UFW rules are added with deterministic `dockwatch:` comments and Dockwatch removes only those managed rules; it never runs `ufw reset`
|
||||
- firewalld uses the selected native zone and managed rich rules while preserving unrelated services, ports, sources and rich rules
|
||||
- native nftables keeps the isolated `table inet dockwatch` model and never flushes the global ruleset
|
||||
- existing provider state/rules are displayed read-only in the firewall editor before changes are applied
|
||||
- provider selection, default inbound management, firewalld zone selection, ALLOW/DENY/REJECT/LIMIT rules and rule reordering are available in the unified UI
|
||||
- provider changes retain the timed rollback/explicit commit safety model; rollback restores the previous Dockwatch-managed policy and provider default snapshot where applicable
|
||||
- package installation/upgrade now installs the selected firewall frontend (`ufw`, `firewalld` or `nftables`) rather than always installing nftables
|
||||
|
||||
## v9.4.1 fixes
|
||||
|
||||
- fixed monitor creation when no monitor is selected (`state.monitor == null`)
|
||||
@@ -148,20 +161,21 @@ Dockwatch can optionally act as a host-security control plane for the selected l
|
||||
|
||||
The **System → Host Security** page provides a posture overview and managed workflows for:
|
||||
|
||||
**Firewall (nftables)**
|
||||
**Firewall (Auto / UFW / firewalld / nftables)**
|
||||
|
||||
- detect whether nftables is installed and whether Dockwatch's policy is active
|
||||
- configure an isolated `table inet dockwatch` INPUT policy
|
||||
- default inbound `ACCEPT` or `DROP`
|
||||
- stateful established/related allowance, loopback and invalid-state handling
|
||||
- optional ICMP/ICMPv6 allowance
|
||||
- trusted IPv4/IPv6 CIDRs
|
||||
- typed TCP/UDP allow/deny port and port-range rules
|
||||
- server-side `nft -c` validation before apply
|
||||
- conflict detection for active UFW/firewalld; Dockwatch refuses to become a second competing firewall owner
|
||||
- persistence through a Dockwatch-owned systemd unit or OpenRC local script
|
||||
- **Auto detect** adopts the already-active host firewall frontend: UFW first when it is the sole active frontend, firewalld when it is the sole active frontend, otherwise native nftables is used when available
|
||||
- an explicit provider can be selected when you intentionally want to migrate or standardize a host
|
||||
- only genuinely competing active frontends are treated as conflicts; for example UFW + firewalld, or UFW + an already-loaded Dockwatch nftables table
|
||||
- the editor shows installed/active frontends plus the provider's existing runtime rules/state before any mutation
|
||||
- common managed policy supports trusted IPv4/IPv6 CIDRs and ordered TCP/UDP **ALLOW / DENY / REJECT / LIMIT** rules
|
||||
- provider-global default inbound management is an explicit opt-in for UFW/firewalld; nftables keeps its default policy inside Dockwatch's own table
|
||||
- **UFW:** Dockwatch uses native `ufw` commands, tags its rules with deterministic `dockwatch:` comments and removes only those tagged rules; it never runs `ufw reset` and preserves foreign UFW rules
|
||||
- **firewalld:** Dockwatch targets a selected zone, uses native rich rules, and preserves unrelated zone services/ports/sources/rich rules; default zone target changes are opt-in
|
||||
- **nftables:** Dockwatch continues to own only `table inet dockwatch`, validates with `nft -c`, never runs `flush ruleset`, and does not alter Docker NAT/FORWARD chains
|
||||
- when an enabled policy is applied to an inactive UFW/firewalld provider, Dockwatch activates the selected frontend inside the rollback window; rollback restores the previous active state
|
||||
- **timed rollback** (30–600 seconds, UI default 90 seconds) after apply; changes must be explicitly kept after management connectivity is verified
|
||||
- no `flush ruleset`, no changes to Docker NAT/FORWARD chains and no arbitrary nftables text accepted from the browser
|
||||
- package installation/upgrade uses the selected provider package instead of always installing nftables
|
||||
- browser clients never submit arbitrary firewall command text; the backend renders and executes only the typed policy model
|
||||
|
||||
**Fail2Ban**
|
||||
|
||||
@@ -184,7 +198,7 @@ The **System → Host Security** page provides a posture overview and managed wo
|
||||
|
||||
**Installation and maintenance**
|
||||
|
||||
- install or upgrade `nftables`, `fail2ban` and `auditd`/`audit` using the detected host package manager
|
||||
- install or upgrade the selected firewall provider (`ufw`, `firewalld` or `nftables`), `fail2ban` and `auditd`/`audit` using the detected host package manager
|
||||
- supported package-manager families: apt, dnf, yum, zypper, apk and pacman
|
||||
- enable, disable, restart and (where meaningful) reload the corresponding host services
|
||||
- current package/service/config-drift information in the UI
|
||||
|
||||
@@ -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
@@ -84,17 +84,18 @@ type Finding struct {
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
OS OSInfo `json:"os"`
|
||||
PackageManager string `json:"package_manager,omitempty"`
|
||||
InitSystem string `json:"init_system,omitempty"`
|
||||
Firewall ComponentStatus `json:"firewall"`
|
||||
Fail2Ban ComponentStatus `json:"fail2ban"`
|
||||
Auditd ComponentStatus `json:"auditd"`
|
||||
Conflicts []string `json:"firewall_conflicts"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Score int `json:"score"`
|
||||
Managed map[string]ManagedConfig `json:"managed"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
OS OSInfo `json:"os"`
|
||||
PackageManager string `json:"package_manager,omitempty"`
|
||||
InitSystem string `json:"init_system,omitempty"`
|
||||
Firewall ComponentStatus `json:"firewall"`
|
||||
FirewallBackend FirewallBackendInfo `json:"firewall_backend"`
|
||||
Fail2Ban ComponentStatus `json:"fail2ban"`
|
||||
Auditd ComponentStatus `json:"auditd"`
|
||||
Conflicts []string `json:"firewall_conflicts"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Score int `json:"score"`
|
||||
Managed map[string]ManagedConfig `json:"managed"`
|
||||
}
|
||||
|
||||
type ManagedConfig struct {
|
||||
@@ -113,22 +114,28 @@ type FirewallRule struct {
|
||||
}
|
||||
|
||||
type FirewallPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DefaultInbound string `json:"default_inbound"`
|
||||
AllowICMP bool `json:"allow_icmp"`
|
||||
TrustedCIDRs []string `json:"trusted_cidrs"`
|
||||
Rules []FirewallRule `json:"rules"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
ResolvedProvider string `json:"resolved_provider,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ManageDefault bool `json:"manage_default"`
|
||||
DefaultInbound string `json:"default_inbound"`
|
||||
Zone string `json:"zone,omitempty"`
|
||||
AllowICMP bool `json:"allow_icmp"`
|
||||
TrustedCIDRs []string `json:"trusted_cidrs"`
|
||||
Rules []FirewallRule `json:"rules"`
|
||||
}
|
||||
|
||||
type FirewallPreview struct {
|
||||
Policy FirewallPolicy `json:"policy"`
|
||||
Rendered string `json:"rendered"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Conflict []string `json:"conflicts"`
|
||||
CanApply bool `json:"can_apply"`
|
||||
Rollback bool `json:"rollback_supported"`
|
||||
ManagedTable string `json:"managed_table"`
|
||||
Persistence string `json:"persistence"`
|
||||
Policy FirewallPolicy `json:"policy"`
|
||||
Backend FirewallBackendInfo `json:"backend"`
|
||||
Runtime FirewallRuntimeView `json:"runtime"`
|
||||
Rendered string `json:"rendered"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Conflict []string `json:"conflicts"`
|
||||
CanApply bool `json:"can_apply"`
|
||||
Rollback bool `json:"rollback_supported"`
|
||||
ManagedTable string `json:"managed_table,omitempty"`
|
||||
Persistence string `json:"persistence"`
|
||||
}
|
||||
|
||||
type FirewallApplyResult struct {
|
||||
@@ -182,13 +189,16 @@ type PolicyResult struct {
|
||||
}
|
||||
|
||||
type InstallInput struct {
|
||||
Enable bool `json:"enable"`
|
||||
Enable bool `json:"enable"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
type pendingFirewall struct {
|
||||
ID string `json:"id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Previous *FirewallPolicy `json:"previous,omitempty"`
|
||||
ID string `json:"id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Previous *FirewallPolicy `json:"previous,omitempty"`
|
||||
Snapshot FirewallRuntimeSnapshot `json:"snapshot,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -221,26 +231,22 @@ func (s *Service) Status(ctx context.Context) Status {
|
||||
osInfo := s.osInfo()
|
||||
pm := s.packageManager()
|
||||
initSystem := s.initSystem()
|
||||
fwPolicy := s.FirewallPolicy()
|
||||
fwBackend := s.firewallBackend(ctx, fwPolicy.Provider)
|
||||
st := Status{
|
||||
Capabilities: caps,
|
||||
OS: osInfo,
|
||||
PackageManager: pm,
|
||||
InitSystem: initSystem,
|
||||
Firewall: s.componentStatus(ctx, "firewall", initSystem),
|
||||
Fail2Ban: s.componentStatus(ctx, "fail2ban", initSystem),
|
||||
Auditd: s.componentStatus(ctx, "auditd", initSystem),
|
||||
Managed: map[string]ManagedConfig{},
|
||||
Capabilities: caps,
|
||||
OS: osInfo,
|
||||
PackageManager: pm,
|
||||
InitSystem: initSystem,
|
||||
Firewall: s.firewallComponentStatus(ctx, initSystem, fwBackend),
|
||||
FirewallBackend: fwBackend,
|
||||
Fail2Ban: s.componentStatus(ctx, "fail2ban", initSystem),
|
||||
Auditd: s.componentStatus(ctx, "auditd", initSystem),
|
||||
Managed: map[string]ManagedConfig{},
|
||||
}
|
||||
st.Conflicts = s.firewallConflicts(ctx)
|
||||
if p, ok := s.loadFirewallPolicy(); ok {
|
||||
rendered, _ := renderFirewall(p)
|
||||
if p.Enabled {
|
||||
st.Managed["firewall"] = s.managedConfig(firewallHostPath, rendered)
|
||||
} else {
|
||||
m := ManagedConfig{Configured: true, Path: firewallHostPath, Hash: hashString(rendered)}
|
||||
m.Drift = fileExists(s.hostPath(firewallHostPath)) || st.Firewall.Active
|
||||
st.Managed["firewall"] = m
|
||||
}
|
||||
st.Conflicts = append([]string(nil), fwBackend.Conflicts...)
|
||||
if _, ok := s.loadFirewallPolicy(); ok {
|
||||
st.Managed["firewall"] = s.firewallManagedConfig(ctx, fwPolicy, fwBackend)
|
||||
st.Firewall.Drift = st.Managed["firewall"].Drift
|
||||
}
|
||||
if p, ok := s.loadFail2BanPolicy(); ok {
|
||||
@@ -268,15 +274,19 @@ func findings(st Status) ([]Finding, int) {
|
||||
score -= 25
|
||||
}
|
||||
if !st.Firewall.Installed {
|
||||
fs = append(fs, Finding{Severity: "high", Title: "nftables not installed", Detail: "Dockwatch cannot manage its isolated inbound firewall table without nftables.", Action: "Install nftables"})
|
||||
provider := st.FirewallBackend.Selected
|
||||
if provider == "" {
|
||||
provider = "firewall"
|
||||
}
|
||||
fs = append(fs, Finding{Severity: "high", Title: "Firewall provider not installed", Detail: "Selected provider " + provider + " is not installed on this host.", Action: "Install " + provider})
|
||||
score -= 25
|
||||
} else if !st.Firewall.Active {
|
||||
fs = append(fs, Finding{Severity: "medium", Title: "Dockwatch firewall policy not active", Detail: "The managed inet/dockwatch nftables table is not currently loaded."})
|
||||
fs = append(fs, Finding{Severity: "medium", Title: "Firewall frontend inactive", Detail: "Selected provider " + st.FirewallBackend.Selected + " is installed but not currently active."})
|
||||
score -= 15
|
||||
}
|
||||
if len(st.Conflicts) > 0 {
|
||||
fs = append(fs, Finding{Severity: "medium", Title: "Another firewall frontend is active", Detail: "Dockwatch detected: " + strings.Join(st.Conflicts, ", ") + ". Managed firewall changes are blocked by default to avoid conflicting rule owners."})
|
||||
score -= 5
|
||||
fs = append(fs, Finding{Severity: "high", Title: "Competing firewall frontends detected", Detail: "Dockwatch detected conflicting active frontends: " + strings.Join(st.Conflicts, ", ") + ". Resolve this before applying changes."})
|
||||
score -= 10
|
||||
}
|
||||
if !st.Fail2Ban.Installed {
|
||||
fs = append(fs, Finding{Severity: "medium", Title: "Fail2Ban not installed", Detail: "Brute-force protection is not available through Dockwatch.", Action: "Install Fail2Ban"})
|
||||
@@ -580,47 +590,84 @@ func (s *Service) persistenceEnabled(ctx context.Context, initSystem, service st
|
||||
}
|
||||
|
||||
func (s *Service) firewallConflicts(ctx context.Context) []string {
|
||||
if !s.capabilitiesNoCommand().TargetVerified {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
x, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if fileExists(s.hostPath("/usr/sbin/ufw")) || fileExists(s.hostPath("/usr/bin/ufw")) {
|
||||
if v, err := s.hostCommand(x, nil, "ufw", "status"); err == nil && strings.Contains(strings.ToLower(v), "status: active") {
|
||||
out = append(out, "ufw")
|
||||
}
|
||||
}
|
||||
if fileExists(s.hostPath("/usr/bin/firewall-cmd")) {
|
||||
if _, err := s.hostCommand(x, nil, "firewall-cmd", "--state"); err == nil {
|
||||
out = append(out, "firewalld")
|
||||
}
|
||||
}
|
||||
return out
|
||||
return append([]string(nil), s.firewallBackend(ctx, FirewallProviderAuto).Conflicts...)
|
||||
}
|
||||
|
||||
func (s *Service) FirewallPolicy() FirewallPolicy {
|
||||
if p, ok := s.loadFirewallPolicy(); ok {
|
||||
if normalizeFirewallProvider(p.Provider) == "" {
|
||||
p.Provider = FirewallProviderAuto
|
||||
}
|
||||
if p.DefaultInbound == "" {
|
||||
p.DefaultInbound = "accept"
|
||||
}
|
||||
if p.TrustedCIDRs == nil {
|
||||
p.TrustedCIDRs = []string{}
|
||||
}
|
||||
if p.Rules == nil {
|
||||
p.Rules = []FirewallRule{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
return FirewallPolicy{Enabled: false, DefaultInbound: "accept", AllowICMP: true, TrustedCIDRs: []string{}, Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Comment: "SSH"}}}
|
||||
return FirewallPolicy{Provider: FirewallProviderAuto, Enabled: false, ManageDefault: false, DefaultInbound: "accept", AllowICMP: true, TrustedCIDRs: []string{}, Rules: []FirewallRule{{Action: "accept", Protocol: "tcp", Port: "22", Comment: "SSH"}}}
|
||||
}
|
||||
|
||||
func (s *Service) PreviewFirewall(ctx context.Context, p FirewallPolicy) (FirewallPreview, error) {
|
||||
rendered, err := renderFirewall(p)
|
||||
provider := normalizeFirewallProvider(p.Provider)
|
||||
if provider == "" {
|
||||
return FirewallPreview{}, errors.New("provider must be auto, ufw, firewalld or nftables")
|
||||
}
|
||||
p.Provider = provider
|
||||
if p.DefaultInbound == "" {
|
||||
p.DefaultInbound = "accept"
|
||||
}
|
||||
if p.TrustedCIDRs == nil {
|
||||
p.TrustedCIDRs = []string{}
|
||||
}
|
||||
if p.Rules == nil {
|
||||
p.Rules = []FirewallRule{}
|
||||
}
|
||||
backend := s.firewallBackend(ctx, provider)
|
||||
p.ResolvedProvider = backend.Selected
|
||||
runtime := s.firewallRuntime(ctx, provider)
|
||||
if backend.Selected == FirewallProviderFirewalld && p.Zone == "" {
|
||||
p.Zone = runtime.Zone
|
||||
if p.Zone == "" {
|
||||
p.Zone = backend.DefaultZone
|
||||
}
|
||||
}
|
||||
if backend.Selected == FirewallProviderFirewalld && p.Zone != "" && (!safeNameRE.MatchString(p.Zone) || strings.HasPrefix(p.Zone, "-")) {
|
||||
return FirewallPreview{}, errors.New("firewalld zone name is invalid")
|
||||
}
|
||||
rendered, warnings, err := renderFirewallPlan(p, backend, runtime)
|
||||
if err != nil {
|
||||
return FirewallPreview{}, err
|
||||
}
|
||||
conflicts := s.firewallConflicts(ctx)
|
||||
warnings := []string{"Dockwatch manages only table inet dockwatch and never flushes the global nftables ruleset.", "Docker forwarding/NAT chains are not changed; this policy hooks only host INPUT traffic."}
|
||||
if p.DefaultInbound == "drop" {
|
||||
if p.DefaultInbound == "drop" && (backend.Selected == FirewallProviderNftables || p.ManageDefault) {
|
||||
warnings = append(warnings, "Default inbound DROP can lock out SSH or Dockwatch. Apply uses a timed rollback until explicitly committed.")
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
warnings = append(warnings, "Another firewall frontend is active: "+strings.Join(conflicts, ", ")+". Apply is blocked to prevent multiple owners of host filtering policy.")
|
||||
if len(backend.Conflicts) > 0 {
|
||||
warnings = append(warnings, "Competing active firewall frontends detected: "+strings.Join(backend.Conflicts, ", ")+". Apply is blocked until only the selected frontend owns host filtering.")
|
||||
}
|
||||
if backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !runtime.Active {
|
||||
warnings = append(warnings, "firewalld is inactive. Start it before changing the zone target so Dockwatch can snapshot the current runtime/permanent target for rollback.")
|
||||
}
|
||||
if oneOf(backend.Selected, FirewallProviderUFW, FirewallProviderFirewalld) && p.Enabled && !runtime.Active {
|
||||
warnings = append(warnings, "The selected firewall frontend is inactive. Applying an enabled Dockwatch policy will activate it; rollback restores the previous active state.")
|
||||
}
|
||||
if !containsString(backend.Available, backend.Selected) {
|
||||
warnings = append(warnings, backend.Selected+" is not installed on this host.")
|
||||
}
|
||||
caps := s.capabilities(ctx)
|
||||
return FirewallPreview{Policy: p, Rendered: rendered, Warnings: warnings, Conflict: conflicts, CanApply: caps.AllowChanges && caps.ExecutorAvailable && caps.HostRootWritable && len(conflicts) == 0, Rollback: true, ManagedTable: "inet dockwatch", Persistence: s.initSystem()}, nil
|
||||
managedTable := ""
|
||||
if backend.Selected == FirewallProviderNftables {
|
||||
managedTable = "inet dockwatch"
|
||||
}
|
||||
canApply := caps.AllowChanges && caps.ExecutorAvailable && caps.HostRootWritable && len(backend.Conflicts) == 0 && containsString(backend.Available, backend.Selected)
|
||||
if backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !runtime.Active {
|
||||
canApply = false
|
||||
}
|
||||
return FirewallPreview{Policy: p, Backend: backend, Runtime: runtime, Rendered: rendered, Warnings: warnings, Conflict: backend.Conflicts, CanApply: canApply, Rollback: true, ManagedTable: managedTable, Persistence: s.initSystem()}, nil
|
||||
}
|
||||
|
||||
func renderFirewall(p FirewallPolicy) (string, error) {
|
||||
@@ -636,8 +683,11 @@ func renderFirewall(p FirewallPolicy) (string, error) {
|
||||
}
|
||||
}
|
||||
for i, r := range p.Rules {
|
||||
if r.Action != "accept" && r.Action != "drop" {
|
||||
return "", fmt.Errorf("rule %d action must be accept or drop", i+1)
|
||||
if !oneOf(r.Action, "accept", "drop", "reject", "limit") {
|
||||
return "", fmt.Errorf("rule %d action must be accept, drop, reject or limit", i+1)
|
||||
}
|
||||
if r.Action == "limit" && r.Protocol != "tcp" {
|
||||
return "", fmt.Errorf("rule %d limit action is supported only for tcp", i+1)
|
||||
}
|
||||
if r.Protocol != "tcp" && r.Protocol != "udp" {
|
||||
return "", fmt.Errorf("rule %d protocol must be tcp or udp", i+1)
|
||||
@@ -687,7 +737,12 @@ func renderFirewall(p FirewallPolicy) (string, error) {
|
||||
comment = "dockwatch " + strings.TrimSpace(r.Comment)
|
||||
}
|
||||
comment = strings.ReplaceAll(comment, `"`, `'`)
|
||||
b.WriteString(fmt.Sprintf(" %s%s dport %s %s comment %s\n", src, r.Protocol, r.Port, r.Action, strconv.Quote(comment)))
|
||||
action := r.Action
|
||||
if action == "limit" {
|
||||
b.WriteString(fmt.Sprintf(" %s%s dport %s ct state new limit rate 6/minute accept comment %s\n", src, r.Protocol, r.Port, strconv.Quote(comment)))
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %s%s dport %s %s comment %s\n", src, r.Protocol, r.Port, action, strconv.Quote(comment)))
|
||||
}
|
||||
b.WriteString(" }\n}\n")
|
||||
return b.String(), nil
|
||||
@@ -715,16 +770,20 @@ func (s *Service) ApplyFirewall(ctx context.Context, p FirewallPolicy, rollbackS
|
||||
if err := s.requireChanges(ctx); err != nil {
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
if len(s.firewallConflicts(ctx)) > 0 {
|
||||
return FirewallApplyResult{}, errors.New("another firewall frontend is active (ufw/firewalld); Dockwatch refuses to apply a competing managed ruleset")
|
||||
}
|
||||
if !fileExists(s.hostPath("/usr/sbin/nft")) && !fileExists(s.hostPath("/sbin/nft")) {
|
||||
return FirewallApplyResult{}, errors.New("nftables is not installed")
|
||||
}
|
||||
preview, err := s.PreviewFirewall(ctx, p)
|
||||
if err != nil {
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
p = preview.Policy
|
||||
if len(preview.Backend.Conflicts) > 0 {
|
||||
return FirewallApplyResult{}, fmt.Errorf("competing firewall frontend(s) active: %s", strings.Join(preview.Backend.Conflicts, ", "))
|
||||
}
|
||||
if !containsString(preview.Backend.Available, preview.Backend.Selected) {
|
||||
return FirewallApplyResult{}, fmt.Errorf("selected firewall provider %s is not installed", preview.Backend.Selected)
|
||||
}
|
||||
if preview.Backend.Selected == FirewallProviderFirewalld && p.ManageDefault && !preview.Runtime.Active {
|
||||
return FirewallApplyResult{}, errors.New("firewalld must be active before Dockwatch can change and safely roll back the zone target")
|
||||
}
|
||||
if rollbackSeconds == 0 {
|
||||
rollbackSeconds = 90
|
||||
}
|
||||
@@ -736,30 +795,34 @@ func (s *Service) ApplyFirewall(ctx context.Context, p FirewallPolicy, rollbackS
|
||||
if hadPrevious {
|
||||
previousPtr = &previous
|
||||
}
|
||||
rollbackState := pendingFirewall{Previous: previousPtr}
|
||||
if p.Enabled {
|
||||
if err := s.validateFirewallRuntime(ctx, preview.Rendered); err != nil {
|
||||
return FirewallApplyResult{}, err
|
||||
snapshot := s.snapshotFirewallRuntime(ctx, preview.Backend.Selected, p.Zone)
|
||||
rollbackState := pendingFirewall{Provider: preview.Backend.Selected, Previous: previousPtr, Snapshot: snapshot}
|
||||
if hadPrevious {
|
||||
oldProvider := s.providerForPolicy(ctx, previous)
|
||||
if oldProvider != "" && oldProvider != preview.Backend.Selected {
|
||||
if err := s.removeCurrentManagedFirewall(ctx, previous); err != nil {
|
||||
return FirewallApplyResult{}, fmt.Errorf("remove previous %s managed firewall before provider switch: %w", oldProvider, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.configureFirewallPersistence(ctx, p.Enabled, preview.Rendered); err != nil {
|
||||
_ = s.rollbackFirewallLocked(ctx, rollbackState)
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
if err := s.applyFirewallRuntime(ctx, p, preview.Rendered); err != nil {
|
||||
if err := s.applyFirewallProvider(ctx, p, previousPtr, preview.Backend); err != nil {
|
||||
s.cleanupAttemptedFirewall(ctx, p, preview.Backend)
|
||||
_ = s.rollbackFirewallLocked(ctx, rollbackState)
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
if err := s.savePolicy("firewall.json", p); err != nil {
|
||||
s.cleanupAttemptedFirewall(ctx, p, preview.Backend)
|
||||
_ = s.rollbackFirewallLocked(ctx, rollbackState)
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
id := randomID()
|
||||
pending := pendingFirewall{ID: id, ExpiresAt: time.Now().Add(time.Duration(rollbackSeconds) * time.Second).Unix(), Previous: previousPtr}
|
||||
pending := pendingFirewall{ID: id, ExpiresAt: time.Now().Add(time.Duration(rollbackSeconds) * time.Second).Unix(), Provider: preview.Backend.Selected, Previous: previousPtr, Snapshot: snapshot}
|
||||
if err := s.savePendingFirewall(pending); err != nil {
|
||||
_ = s.rollbackFirewallLocked(ctx, rollbackState)
|
||||
return FirewallApplyResult{}, err
|
||||
}
|
||||
s.scheduleFirewallRollback(pending)
|
||||
return FirewallApplyResult{OK: true, ChangeID: id, ExpiresAt: pending.ExpiresAt, Preview: preview, Message: "Firewall applied temporarily. Commit the change before the rollback timer expires."}, nil
|
||||
return FirewallApplyResult{OK: true, ChangeID: id, ExpiresAt: pending.ExpiresAt, Preview: preview, Message: "Firewall policy applied through " + preview.Backend.Selected + ". Commit the change before the rollback timer expires."}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CommitFirewall(id string) error {
|
||||
@@ -917,25 +980,43 @@ func (s *Service) scheduleFirewallRollback(p pendingFirewall) {
|
||||
}
|
||||
|
||||
func (s *Service) rollbackFirewallLocked(ctx context.Context, p pendingFirewall) error {
|
||||
current, hasCurrent := s.loadFirewallPolicy()
|
||||
if p.Previous == nil {
|
||||
disabled := FirewallPolicy{Enabled: false, DefaultInbound: "accept", AllowICMP: true}
|
||||
if err := s.configureFirewallPersistence(ctx, false, ""); err != nil {
|
||||
return err
|
||||
if hasCurrent {
|
||||
if err := s.removeCurrentManagedFirewall(ctx, current); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.applyFirewallRuntime(ctx, disabled, ""); err != nil {
|
||||
if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(s.securityPath("firewall.json"))
|
||||
return nil
|
||||
}
|
||||
rendered, err := renderFirewall(*p.Previous)
|
||||
if err != nil {
|
||||
return err
|
||||
provider := s.providerForPolicy(ctx, *p.Previous)
|
||||
backend := s.firewallBackend(ctx, provider)
|
||||
var currentPtr *FirewallPolicy
|
||||
restoredSnapshot := false
|
||||
if hasCurrent {
|
||||
currentPtr = ¤t
|
||||
currentProvider := s.providerForPolicy(ctx, current)
|
||||
if currentProvider != "" && currentProvider != provider {
|
||||
if err := s.removeCurrentManagedFirewall(ctx, current); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
restoredSnapshot = true
|
||||
currentPtr = nil
|
||||
}
|
||||
}
|
||||
if err := s.configureFirewallPersistence(ctx, p.Previous.Enabled, rendered); err != nil {
|
||||
return err
|
||||
if !restoredSnapshot && !p.Previous.ManageDefault && oneOf(p.Snapshot.Provider, FirewallProviderUFW, FirewallProviderFirewalld) {
|
||||
if err := s.restoreFirewallSnapshot(ctx, p.Snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.applyFirewallRuntime(ctx, *p.Previous, rendered); err != nil {
|
||||
if err := s.applyFirewallProvider(ctx, *p.Previous, currentPtr, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.savePolicy("firewall.json", *p.Previous)
|
||||
@@ -1185,7 +1266,13 @@ func (s *Service) Install(ctx context.Context, component string, in InstallInput
|
||||
if err := s.requirePackages(ctx); err != nil {
|
||||
return PolicyResult{}, err
|
||||
}
|
||||
pkg, service, err := s.packageFor(component)
|
||||
var pkg, service string
|
||||
var err error
|
||||
if component == "firewall" {
|
||||
pkg, service, err = s.firewallPackage(in.Provider)
|
||||
} else {
|
||||
pkg, service, err = s.packageFor(component)
|
||||
}
|
||||
if err != nil {
|
||||
return PolicyResult{}, err
|
||||
}
|
||||
@@ -1232,8 +1319,23 @@ func (s *Service) Install(ctx context.Context, component string, in InstallInput
|
||||
default:
|
||||
return PolicyResult{}, errors.New("unsupported package manager")
|
||||
}
|
||||
if in.Enable && service != "" {
|
||||
_, _ = s.serviceActionUnlocked(ctx, component, "enable")
|
||||
if component == "firewall" {
|
||||
// Persist an explicit provider selection even when installation does not
|
||||
// activate the frontend. This keeps Auto from immediately switching back
|
||||
// to another already-installed firewall after package installation.
|
||||
p := s.FirewallPolicy()
|
||||
if v := normalizeFirewallProvider(in.Provider); v != "" && v != FirewallProviderAuto {
|
||||
p.Provider = v
|
||||
p.ResolvedProvider = v
|
||||
_ = s.savePolicy("firewall.json", p)
|
||||
}
|
||||
}
|
||||
if in.Enable {
|
||||
if component == "firewall" {
|
||||
_, _ = s.firewallServiceAction(ctx, "enable")
|
||||
} else if service != "" {
|
||||
_, _ = s.serviceActionUnlocked(ctx, component, "enable")
|
||||
}
|
||||
}
|
||||
return PolicyResult{OK: true, Message: component + " installed/updated.", Output: limit(out, 12000)}, nil
|
||||
}
|
||||
@@ -1258,7 +1360,10 @@ func (s *Service) serviceActionUnlocked(ctx context.Context, component, action s
|
||||
if !oneOf(action, "enable", "disable", "restart", "reload", "start", "stop") {
|
||||
return "", errors.New("unsupported service action")
|
||||
}
|
||||
service := map[string]string{"firewall": "dockwatch-firewall", "fail2ban": "fail2ban", "auditd": "auditd"}[component]
|
||||
if component == "firewall" {
|
||||
return s.firewallServiceAction(ctx, action)
|
||||
}
|
||||
service := map[string]string{"fail2ban": "fail2ban", "auditd": "auditd"}[component]
|
||||
if component == "auditd" && action == "reload" {
|
||||
x, c := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer c()
|
||||
@@ -1302,7 +1407,7 @@ func (s *Service) packageFor(component string) (pkg, service string, err error)
|
||||
osid := s.osInfo().ID
|
||||
switch component {
|
||||
case "firewall":
|
||||
return "nftables", "", nil
|
||||
return s.firewallPackage(FirewallProviderAuto)
|
||||
case "fail2ban":
|
||||
return "fail2ban", "fail2ban", nil
|
||||
case "auditd":
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -216,12 +216,13 @@ async function renderSecurity(){
|
||||
try{
|
||||
const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{};
|
||||
window.__securityCaps=c;
|
||||
window.__firewallBackend=d.firewall_backend||{};
|
||||
const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit';
|
||||
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}
|
||||
<div class="securityHero ${capClass}"><div><small>HOST SECURITY POSTURE</small><div class="securityScore">${Number(d.score||0)}<span>/100</span></div><p>${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}</p></div><div class="securityCaps">${securityCapabilityPills(c)}</div></div>
|
||||
${c.reason?`<div class="notice ${c.enabled?'':'warnNotice'}"><b>Capability:</b> ${esc(c.reason)}</div>`:''}
|
||||
<div class="securityGrid">
|
||||
${securityComponentCard('firewall','Firewall · nftables','Isolated host INPUT policy. Docker NAT/FORWARD chains remain untouched.',d.firewall,managed.firewall,d.conflicts)}
|
||||
${securityComponentCard('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Auto-detected native firewall management through UFW, firewalld or isolated nftables.',d.firewall,managed.firewall,d.conflicts)}
|
||||
${securityComponentCard('fail2ban','Fail2Ban','Rate-limit and ban repeated authentication failures using managed jail.d overrides.',d.fail2ban,managed.fail2ban)}
|
||||
${securityComponentCard('auditd','Linux Audit · auditd','Track changes to identity, SSH, sudo, Docker and selected host paths.',d.auditd,managed.auditd)}
|
||||
</div>
|
||||
@@ -236,17 +237,19 @@ async function renderSecurity(){
|
||||
}catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}<div class="empty red">${esc(e.message)}</div>`}
|
||||
}
|
||||
function securityCapabilityPills(c){return `<span class="tag ${c.host_root_available?'oktag':''}">host root ${c.host_root_available?'✓':'×'}</span><span class="tag ${c.target_verified?'oktag':''}">host namespace ${c.target_verified?'✓':'×'}</span><span class="tag ${c.allow_changes?'oktag':''}">${c.allow_changes?'manage':'audit only'}</span><span class="tag ${c.allow_package_management?'oktag':''}">packages ${c.allow_package_management?'enabled':'locked'}</span>`}
|
||||
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"';return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${esc(title)}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installed</small><b>${installed?'Yes':'No'}</b></span><span><small>Runtime</small><b class="${active?'green':'muted'}">${active?'Active':'Inactive'}</b></span><span><small>Boot</small><b>${st.enabled?'Enabled':'—'}</b></span><span><small>Config</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice warnNotice"><b>Conflict:</b> ${esc(conflicts.join(', '))} active. Dockwatch firewall apply is blocked.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Install</button>`:''}<button class="btn" id="configure-${key}" ${!installed||!manage?'disabled':''} ${!manage?'title="Host security changes are disabled for this environment"':''}>Configure</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Enable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Disable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Restart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Reload</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Uses the host package manager to install the currently available package version">Upgrade</button>`:''}</div></section>`}
|
||||
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"',canConfigure=manage&&(key==='firewall'||installed);return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${title}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installed</small><b>${installed?'Yes':'No'}</b></span><span><small>Runtime</small><b class="${active?'green':'muted'}">${active?'Active':'Inactive'}</b></span><span><small>Boot</small><b>${st.enabled?'Enabled':'—'}</b></span><span><small>Config</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice dangerNotice"><b>Firewall conflict:</b> ${esc(conflicts.join(', '))}. Resolve competing active frontends before applying changes.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Install</button>`:''}<button class="btn" id="configure-${key}" ${!canConfigure?'disabled':''} ${!manage?'title="Host security changes are disabled for this environment"':''}>Configure</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Enable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Disable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Restart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Reload</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Uses the host package manager to install the currently available package version">Upgrade</button>`:''}</div></section>`}
|
||||
function securityFindings(rows){if(!rows.length)return '<div class="empty">No findings.</div>';return `<div class="securityFindings">${rows.map(f=>`<div class="securityFinding sev-${esc(f.severity)}"><span>${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}</span><div><b>${esc(f.title)}</b><p>${esc(f.detail)}</p>${f.action?`<small>${esc(f.action)}</small>`:''}</div></div>`).join('')}</div>`}
|
||||
async function securityInstall(component,btn){if(!confirm(`Install or upgrade ${component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall'})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`Install or upgrade ${component==='firewall'?provider:component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode());firewallEditor(p)}catch(e){toast(e.message)}}
|
||||
function firewallEditor(p){const rules=asArray(p.rules);modal(`<div class="modalhead"><h2>Managed nftables firewall</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Scope:</b> Dockwatch manages only <code>table inet dockwatch</code> and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.</div><div class="fieldgrid" style="margin-top:12px"><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch firewall policy</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div><label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''}> Allow ICMP / IPv6 ICMP</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. You must explicitly keep the rules after confirming that this UI is still reachable. Default DROP requires that you add the management ports/CIDRs you need.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview nftables</button><button class="btn danger" id="fwApply">Apply with rollback</button></div>`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview('nftables preview',x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy();if(policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action!=='drop'?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 or 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Source CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Comment" value="${esc(r.comment||'')}"><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
|
||||
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove())}
|
||||
function collectFirewallPolicy(){return {enabled:$('#fwEnabled').checked,default_inbound:$('#fwDefault').value,allow_icmp:$('#fwICMP').checked,trusted_cidrs:$('#fwTrusted').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
|
||||
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
|
||||
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Auto detect':v}</option>`).join('')}
|
||||
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Detected host firewall</h3><span class="tag">selected: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Available</small><b>${esc(available.join(', ')||'none')}</b></span><span><small>Active</small><b>${esc(active.join(', ')||'none')}</b></span><span><small>Default</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(b.reason)}</div>`:''}<div class="field"><label>Existing native rules/state <span class="muted">· read-only · foreign rules are preserved</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'No runtime rules reported.')}</pre></div></div>`}
|
||||
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider model:</b> Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs <code>ufw reset</code> or <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch-managed rules</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Manage provider default inbound ${selected==='nftables'?'<span class="muted">(native policy is always local to Dockwatch table)</span>':''}</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> Allow ICMP / IPv6 ICMP ${selected!=='nftables'?'<span class="muted">(kept native by this provider)</span>':''}</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Dockwatch-managed port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>Install ${esc(selected)}</button>`:''}<button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Apply with rollback</button></div>`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action==='accept'||!r.action?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option><option value="reject" ${r.action==='reject'?'selected':''}>REJECT</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMIT</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 or 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Source CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Comment" value="${esc(r.comment||'')}"><button class="iconbtn fwUp" title="Move up">↑</button><button class="iconbtn fwDown" title="Move down">↓</button><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
|
||||
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
|
||||
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
|
||||
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${warnings.map(x=>`<div class="notice">${esc(x)}</div>`).join('')}${conflicts?.length?`<div class="notice dangerNotice">Conflicts: ${esc(conflicts.join(', '))}</div>`:''}<pre class="terminal" style="max-height:55vh">${esc(text||'')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
|
||||
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall applied · verification window</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Do not close this dialog yet.</b> If the new policy breaks access, Dockwatch will restore the previous managed firewall policy automatically.</div><div class="securityCountdown"><small>Automatic rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Verify SSH and any other management path in a separate session. Then keep the change.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Rollback now</button><button class="btn primary" id="fwCommitNow">Keep changes</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
|
||||
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall applied · verification window</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Do not close this dialog yet.</b> If the new policy breaks access, Dockwatch will restore the previous Dockwatch-managed policy and provider default snapshot automatically.</div><div class="securityCountdown"><small>Automatic rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Verify SSH and any other management path in a separate session. Then keep the change.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Rollback now</button><button class="btn primary" id="fwCommitNow">Keep changes</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
|
||||
async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}}
|
||||
function fail2banEditor(p){modal(`<div class="modalhead"><h2>Fail2Ban policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/fail2ban/jail.d/dockwatch.local</code>. Other distro/user jails are preserved and remain effective.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Ban time</label><input id="f2bBan" value="${esc(p.bantime||'1h')}"></div><div class="field"><label>Find time</label><input id="f2bFind" value="${esc(p.findtime||'10m')}"></div><div class="field"><label>Max retry</label><input id="f2bRetry" type="number" min="1" max="1000" value="${esc(p.maxretry||5)}"></div><div class="field"><label>Backend</label><select id="f2bBackend"><option>auto</option><option>systemd</option><option>polling</option><option>pyinotify</option></select></div><div class="field full"><label>Ignore IP/CIDR · one per line</label><textarea id="f2bIgnore">${esc(asArray(p.ignore_ip).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Jails</h3><button class="btn tiny" id="f2bAdd">+ Jail</button></div><div id="f2bJails">${asArray(p.jails).map(fail2banJailRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="f2bSave">Validate & apply</button></div>`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban applied');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function fail2banJailRow(j={}){return `<div class="f2bJail securityFormRow"><label class="switch"><input class="f2bEnabled" type="checkbox" ${j.enabled!==false?'checked':''}> enabled</label><input class="f2bName" placeholder="jail name" value="${esc(j.name||'')}"><input class="f2bPort" placeholder="port · ssh" value="${esc(j.port||'')}"><input class="f2bFilter" placeholder="filter" value="${esc(j.filter||'')}"><select class="f2bBackend"><option ${j.backend==='auto'||!j.backend?'selected':''}>auto</option><option ${j.backend==='systemd'?'selected':''}>systemd</option><option ${j.backend==='polling'?'selected':''}>polling</option><option ${j.backend==='pyinotify'?'selected':''}>pyinotify</option></select><input class="f2bLog" placeholder="log path · optional" value="${esc(j.logpath||'')}"><input class="f2bMax" type="number" min="0" max="1000" placeholder="retries" value="${esc(j.maxretry||'')}"><button class="iconbtn f2bRemove">×</button></div>`}
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Dockwatch</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=9.4.1">
|
||||
<link rel="stylesheet" href="/styles.css?v=9.5">
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
@@ -39,5 +39,5 @@
|
||||
</main>
|
||||
</div>
|
||||
<div id="modalRoot"></div><div id="toast"></div>
|
||||
<script src="/app.js?v=9.4.1"></script>
|
||||
<script src="/app.js?v=9.5"></script>
|
||||
</body></html>
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
|
||||
.mono{font:11px/1.45 "Cascadia Code","SFMono-Regular",Consolas,monospace}.warn{color:var(--amber)}.compactList{margin:8px 0 0;padding-left:18px;color:var(--muted)}.compactList li{margin:6px 0}.panel.inset{padding:12px;background:var(--panel2);overflow:visible}.panel.inset h3{margin:0;font-size:12px}.dangerNotice{border-color:#592733!important;background:#2b171e!important;color:#f1bcc5!important}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:7px}.tablewrap .table{min-width:720px}
|
||||
|
||||
/* Host Security layer */
|
||||
.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px 28px 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.fwRuntime{margin-top:12px}.fwExisting{font-size:9px;line-height:1.45;white-space:pre-wrap}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
@media(max-width:1300px){.securityGrid{grid-template-columns:1fr}.securityFacts{grid-template-columns:repeat(4,1fr)}}
|
||||
@media(max-width:860px){.securityHero{align-items:flex-start;flex-direction:column}.securityCaps{justify-content:flex-start}.securityFacts{grid-template-columns:repeat(2,1fr)}.securitySafety{grid-template-columns:1fr}.fwRule{grid-template-columns:1fr 1fr}.fwRule .fwPort,.fwRule .fwSource,.fwRule .fwComment{grid-column:span 2}.securityFormRow{grid-template-columns:1fr 1fr}.securityFormRow .f2bLog{grid-column:span 2}.securityFormRow.audit{grid-template-columns:1fr}.securityChecks{grid-template-columns:1fr}}
|
||||
.volume-reconcile{margin-bottom:14px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}
|
||||
|
||||
Reference in New Issue
Block a user