diff --git a/.env.example b/.env.example index 1fcd212..c0e8adf 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,9 @@ AUDIT_RETENTION_DAYS=180 HOST_ROOT= ALLOW_HOST_USER_MANAGEMENT=false ALLOW_HOST_PERMISSION_MANAGEMENT=false + +# Host Security layer. Audit is separate from mutation/package privileges. +HOST_SECURITY_ENABLED=false +ALLOW_HOST_SECURITY_CHANGES=false +ALLOW_HOST_PACKAGE_MANAGEMENT=false +HOST_SECURITY_HOST_PID=1 diff --git a/Dockerfile b/Dockerfile index c1e375b..3a5adb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ./web/embed.go || \ +RUN test -f ./cmd/dockwatch/main.go && test -f ./internal/stacks/stacks.go && test -f ./internal/hostsecurity/security.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 @@ -21,7 +21,7 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache FROM docker:cli ENV DOCKER_CONFIG=/data/docker-config -RUN apk add --no-cache ca-certificates tzdata git openssh-client acl +RUN apk add --no-cache ca-certificates tzdata git openssh-client acl util-linux COPY --from=build /out/dockwatch /usr/local/bin/dockwatch VOLUME ["/data","/stacks"] EXPOSE 8080 diff --git a/README.md b/README.md index 299a055..f34302d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Dockwatch v9.3.2 +# Dockwatch v9.4 > Go module: `git.send.nrw/sendnrw/dockwatch` @@ -132,6 +132,98 @@ Ownership repair can operate on only the bind root or recursively. `chmod` is se Docker positional arguments and Compose service names are validated before invoking the CLI so option-like values cannot be interpreted as Docker CLI flags. +### Host Security layer + +Dockwatch can optionally act as a host-security control plane for the selected local or remote-agent environment. The feature is **disabled by default** and uses three independent capabilities so that audit, configuration changes and package management can be granted separately. + +The **System → Host Security** page provides a posture overview and managed workflows for: + +**Firewall (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 +- **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 + +**Fail2Ban** + +- package/runtime/boot status +- package installation/upgrade when separately enabled +- managed global ban/find/max-retry/backend settings +- ignore IP/CIDR list +- configurable jails with filter, port, backend, log path and per-jail retry threshold +- server-side `fail2ban-client -t` validation before activation/reload +- Dockwatch owns only `/etc/fail2ban/jail.d/dockwatch.local`; distro and administrator configuration is preserved + +**Linux Audit (auditd)** + +- package/runtime/boot status +- managed watches for identity files, sudoers, SSH configuration, Docker socket/configuration, systemd units and kernel-module configuration +- additional explicitly configured file watches +- path/key/permission validation +- `augenrules --check` validation and `augenrules --load` activation +- Dockwatch owns only `/etc/audit/rules.d/90-dockwatch.rules`; unrelated audit rules remain untouched + +**Installation and maintenance** + +- install or upgrade `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 +- backups of Dockwatch-managed host configuration before replacement +- all mutation endpoints are administrator-only and flow through Dockwatch's existing origin/CSRF guard and activity audit log + +Dockwatch intentionally manages its own drop-in/configuration scope rather than rewriting the host's entire security configuration. It is a focused administration layer, not a replacement for a complete CIS/STIG benchmark, SELinux/AppArmor policy management, an enterprise EDR, or distro-specific security tooling. + +#### Capability modes + +Read-only file/configuration audit: + +```yaml +services: + dockwatch: + environment: + HOST_ROOT: /host + HOST_SECURITY_ENABLED: "true" + ALLOW_HOST_SECURITY_CHANGES: "false" + ALLOW_HOST_PACKAGE_MANAGEMENT: "false" + volumes: + - /:/host:ro +``` + +A ready-to-use example is `examples/compose-host-security-audit.override.yml`. In this mode Dockwatch cannot mutate the host. Runtime/service checks that require entering the host namespaces may be unavailable and are reported as such. + +Full host-security management is intentionally high privilege: + +```yaml +services: + dockwatch: + pid: host + privileged: true + environment: + HOST_ROOT: /host + HOST_SECURITY_ENABLED: "true" + ALLOW_HOST_SECURITY_CHANGES: "true" + ALLOW_HOST_PACKAGE_MANAGEMENT: "true" + HOST_SECURITY_HOST_PID: "1" + volumes: + - /:/host:rw +``` + +See `examples/compose-host-security.override.yml`. Dockwatch verifies that `HOST_ROOT` and `/proc//root` refer to the same host before enabling its namespace executor. Host commands are executed through `nsenter` in the target host namespaces; the browser can select only predefined operations and cannot submit arbitrary shell commands. + +Because this mode grants Dockwatch broad host-administration capability, use OIDC, restrict the `admin` role carefully, protect the Dockwatch database/`APP_SECRET`, and do not expose an agent token or the UI to untrusted networks. + +The same security APIs are available through the existing master/agent relay, so each agent can audit/manage **its own host** when that agent has been configured with the appropriate host-security capability. A master does not silently inherit host privileges on an agent. + ### Monitoring Probe types: @@ -254,6 +346,7 @@ Remote-capable features include: - container identity analysis, bind-mount permission repair and optional host-account creation on the agent host - monitoring probes - Git clone/sync/deploy +- host-security audit/management when explicitly enabled on that agent See `examples/compose-master.yml` and `examples/compose-agent.yml`. @@ -323,6 +416,10 @@ AUDIT_RETENTION_DAYS=180 HOST_ROOT= ALLOW_HOST_USER_MANAGEMENT=false ALLOW_HOST_PERMISSION_MANAGEMENT=false +HOST_SECURITY_ENABLED=false +ALLOW_HOST_SECURITY_CHANGES=false +ALLOW_HOST_PACKAGE_MANAGEMENT=false +HOST_SECURITY_HOST_PID=1 ``` `AUTH_DISABLED=true` is for local development only. Do not expose that configuration publicly. @@ -374,17 +471,17 @@ SQLite database: The artifact-building environment cannot reach `proxy.golang.org`, so it cannot download the real external modules or generate a trustworthy `go.sum` here. The repository intentionally does **not** ship fake checksums or test stubs. -For quality control, the project is copied into a temporary test workspace where API-compatible local stubs replace only the external dependencies. Those stubs are not included in the ZIP. The checks used for v9 include: +For quality control, the project is copied into a temporary test workspace where API-compatible local stubs replace only the external dependencies. Those stubs are not included in the ZIP. The checks used for this source snapshot include: ```text # all packages type/compile checked with temporary external-module stubs go test -run='^$' ./... go test ./internal/config ./internal/gitops ./internal/httpapi \ - ./internal/monitor ./internal/nodes ./internal/notify ./internal/stacks + ./internal/monitor ./internal/nodes ./internal/notify ./internal/stacks ./internal/hostsecurity go vet ./... -go test -race ./internal/config ./internal/gitops ./internal/httpapi ./internal/monitor ./internal/nodes ./internal/notify ./internal/stacks +go test -race ./internal/config ./internal/gitops ./internal/httpapi ./internal/monitor ./internal/nodes ./internal/notify ./internal/stacks ./internal/hostsecurity node --check web/app.js ``` diff --git a/cmd/dockwatch/main.go b/cmd/dockwatch/main.go index b2f14f4..43e6b4a 100644 --- a/cmd/dockwatch/main.go +++ b/cmd/dockwatch/main.go @@ -9,6 +9,7 @@ import ( "git.send.nrw/sendnrw/dockwatch/internal/config" database "git.send.nrw/sendnrw/dockwatch/internal/db" "git.send.nrw/sendnrw/dockwatch/internal/gitops" + "git.send.nrw/sendnrw/dockwatch/internal/hostsecurity" "git.send.nrw/sendnrw/dockwatch/internal/httpapi" "git.send.nrw/sendnrw/dockwatch/internal/monitor" "git.send.nrw/sendnrw/dockwatch/internal/nodes" @@ -48,6 +49,14 @@ func main() { au := audit.New(db) nt := notify.New(db, cfg.EncryptionKey()) gs := gitops.New(db, cfg.EncryptionKey(), ss, nm) + hs := hostsecurity.New(hostsecurity.Config{ + Enabled: cfg.HostSecurityEnabled, + AllowChanges: cfg.AllowHostSecurityChanges, + AllowPackageManagement: cfg.AllowHostPackageManagement, + HostRoot: cfg.HostRoot, + DataDir: cfg.DataDir, + HostPID: cfg.HostSecurityPID, + }) ms.SetEventSink(func(ctx context.Context, ev monitor.Event) { nt.Broadcast(ctx, notify.Message{Title: "Monitor " + ev.To + ": " + ev.Name, Body: ev.Target + " changed from " + ev.From + " to " + ev.To + ". " + ev.Check.Message, Status: ev.To, MonitorID: ev.MonitorID}) _ = au.Log(ctx, audit.Entry{Actor: "monitor", Action: "monitor.transition", Resource: ev.Name, Detail: map[string]any{"monitor_id": ev.MonitorID, "from": ev.From, "to": ev.To, "latency_ms": ev.Check.LatencyMS}, Status: 200}) @@ -63,7 +72,7 @@ func main() { } srv := &http.Server{ Addr: cfg.ListenAddr, - Handler: httpapi.New(cfg, as, ss, nm, ms, au, nt, gs), + Handler: httpapi.New(cfg, as, ss, nm, ms, au, nt, gs, hs), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute, diff --git a/compose.yml b/compose.yml index cdefcbd..6e0e055 100644 --- a/compose.yml +++ b/compose.yml @@ -20,6 +20,10 @@ services: HOST_ROOT: "${HOST_ROOT:-}" ALLOW_HOST_USER_MANAGEMENT: "${ALLOW_HOST_USER_MANAGEMENT:-false}" ALLOW_HOST_PERMISSION_MANAGEMENT: "${ALLOW_HOST_PERMISSION_MANAGEMENT:-false}" + HOST_SECURITY_ENABLED: "${HOST_SECURITY_ENABLED:-false}" + ALLOW_HOST_SECURITY_CHANGES: "${ALLOW_HOST_SECURITY_CHANGES:-false}" + ALLOW_HOST_PACKAGE_MANAGEMENT: "${ALLOW_HOST_PACKAGE_MANAGEMENT:-false}" + HOST_SECURITY_HOST_PID: "${HOST_SECURITY_HOST_PID:-1}" volumes: - ./data:/data - ./stacks:/stacks diff --git a/examples/compose-agent.yml b/examples/compose-agent.yml index ce0559e..938b796 100644 --- a/examples/compose-agent.yml +++ b/examples/compose-agent.yml @@ -11,6 +11,10 @@ services: HOST_ROOT: "" ALLOW_HOST_USER_MANAGEMENT: "false" ALLOW_HOST_PERMISSION_MANAGEMENT: "false" + HOST_SECURITY_ENABLED: "false" + ALLOW_HOST_SECURITY_CHANGES: "false" + ALLOW_HOST_PACKAGE_MANAGEMENT: "false" + HOST_SECURITY_HOST_PID: "1" volumes: - ./agent-data:/data - ./agent-stacks:/stacks diff --git a/internal/config/config.go b/internal/config/config.go index d07c0e9..a1b48d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,8 @@ type Config struct { OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string AgentToken, HostRoot string AllowHostUserManagement, AllowHostPermissionManagement bool + HostSecurityEnabled, AllowHostSecurityChanges, AllowHostPackageManagement bool + HostSecurityPID int CheckConcurrency, RetentionDays, AuditRetentionDays int HTTPTimeout time.Duration } @@ -61,6 +63,22 @@ func Load() (Config, error) { if err != nil { return Config{}, err } + hostSecurityEnabled, err := envBoolStrict("HOST_SECURITY_ENABLED", false) + if err != nil { + return Config{}, err + } + allowHostSecurityChanges, err := envBoolStrict("ALLOW_HOST_SECURITY_CHANGES", false) + if err != nil { + return Config{}, err + } + allowHostPackageManagement, err := envBoolStrict("ALLOW_HOST_PACKAGE_MANAGEMENT", false) + if err != nil { + return Config{}, err + } + hostSecurityPID, err := envIntStrict("HOST_SECURITY_HOST_PID", 1) + if err != nil { + return Config{}, err + } c := Config{ Mode: Mode(env("APP_MODE", "standalone")), ListenAddr: env("LISTEN_ADDR", ":8080"), @@ -79,6 +97,10 @@ func Load() (Config, error) { HostRoot: cleanOptionalPath(os.Getenv("HOST_ROOT")), AllowHostUserManagement: allowHostUserManagement, AllowHostPermissionManagement: allowHostPermissionManagement, + HostSecurityEnabled: hostSecurityEnabled, + AllowHostSecurityChanges: allowHostSecurityChanges, + AllowHostPackageManagement: allowHostPackageManagement, + HostSecurityPID: hostSecurityPID, CheckConcurrency: checkConcurrency, RetentionDays: retentionDays, AuditRetentionDays: auditRetentionDays, @@ -120,6 +142,18 @@ func Load() (Config, error) { if c.AllowHostPermissionManagement && c.HostRoot == "" { return c, errors.New("ALLOW_HOST_PERMISSION_MANAGEMENT=true requires HOST_ROOT") } + if c.HostSecurityEnabled && c.HostRoot == "" { + return c, errors.New("HOST_SECURITY_ENABLED=true requires HOST_ROOT") + } + if c.AllowHostSecurityChanges && !c.HostSecurityEnabled { + return c, errors.New("ALLOW_HOST_SECURITY_CHANGES=true requires HOST_SECURITY_ENABLED=true") + } + if c.AllowHostPackageManagement && !c.AllowHostSecurityChanges { + return c, errors.New("ALLOW_HOST_PACKAGE_MANAGEMENT=true requires ALLOW_HOST_SECURITY_CHANGES=true") + } + if c.HostSecurityPID < 1 { + return c, errors.New("HOST_SECURITY_HOST_PID must be greater than 0") + } if c.Mode == ModeAgent { if len(c.AgentToken) < 24 { return c, errors.New("AGENT_TOKEN must be at least 24 characters in agent mode") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index eaf0110..3698555 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -42,3 +42,24 @@ func TestHostPermissionManagementRequiresHostRoot(t *testing.T) { t.Fatal("expected host permission management without HOST_ROOT to fail") } } + +func TestHostSecurityRequiresHostRoot(t *testing.T) { + t.Setenv("AUTH_DISABLED", "true") + t.Setenv("APP_SECRET", "01234567890123456789012345678901") + t.Setenv("HOST_SECURITY_ENABLED", "true") + t.Setenv("HOST_ROOT", "") + if _, err := Load(); err == nil { + t.Fatal("expected host security without HOST_ROOT to fail") + } +} + +func TestHostSecurityPackageManagementRequiresChanges(t *testing.T) { + t.Setenv("AUTH_DISABLED", "true") + t.Setenv("APP_SECRET", "01234567890123456789012345678901") + t.Setenv("HOST_ROOT", "/host") + t.Setenv("HOST_SECURITY_ENABLED", "true") + t.Setenv("ALLOW_HOST_PACKAGE_MANAGEMENT", "true") + if _, err := Load(); err == nil { + t.Fatal("expected package management without security changes opt-in to fail") + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 7ef6ec4..02f0f10 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -13,6 +13,7 @@ import ( "net/url" "strconv" "strings" + "time" "git.send.nrw/sendnrw/dockwatch/internal/audit" "git.send.nrw/sendnrw/dockwatch/internal/auth" @@ -20,6 +21,7 @@ import ( "git.send.nrw/sendnrw/dockwatch/internal/composeedit" "git.send.nrw/sendnrw/dockwatch/internal/config" "git.send.nrw/sendnrw/dockwatch/internal/gitops" + "git.send.nrw/sendnrw/dockwatch/internal/hostsecurity" "git.send.nrw/sendnrw/dockwatch/internal/monitor" "git.send.nrw/sendnrw/dockwatch/internal/nodes" "git.send.nrw/sendnrw/dockwatch/internal/notify" @@ -37,10 +39,11 @@ type Server struct { audit *audit.Service notify *notify.Service git *gitops.Service + security *hostsecurity.Service } -func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, m *monitor.Service, au *audit.Service, nt *notify.Service, gs *gitops.Service) http.Handler { - s := &Server{cfg: c, auth: a, stacks: ss, nodes: n, monitors: m, audit: au, notify: nt, git: gs} +func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, m *monitor.Service, au *audit.Service, nt *notify.Service, gs *gitops.Service, hs *hostsecurity.Service) http.Handler { + s := &Server{cfg: c, auth: a, stacks: ss, nodes: n, monitors: m, audit: au, notify: nt, git: gs, security: hs} mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{"ok": true, "mode": c.Mode, "build": buildinfo.Current()}) @@ -129,6 +132,18 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager, api.Handle("PUT /api/nodes/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateNode))) api.Handle("DELETE /api/nodes/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteNode))) api.HandleFunc("GET /api/nodes/{id}/health", s.nodeHealth) + api.Handle("GET /api/security/status", auth.RequireRole("admin", http.HandlerFunc(s.securityStatus))) + api.Handle("GET /api/security/firewall", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewall))) + api.Handle("POST /api/security/firewall/preview", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallPreview))) + api.Handle("POST /api/security/firewall/apply", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallApply))) + api.Handle("POST /api/security/firewall/commit", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallCommit))) + api.Handle("POST /api/security/firewall/rollback", auth.RequireRole("admin", http.HandlerFunc(s.securityFirewallRollback))) + api.Handle("GET /api/security/fail2ban", auth.RequireRole("admin", http.HandlerFunc(s.securityFail2Ban))) + api.Handle("PUT /api/security/fail2ban", auth.RequireRole("admin", http.HandlerFunc(s.securityApplyFail2Ban))) + api.Handle("GET /api/security/auditd", auth.RequireRole("admin", http.HandlerFunc(s.securityAuditd))) + api.Handle("PUT /api/security/auditd", auth.RequireRole("admin", http.HandlerFunc(s.securityApplyAuditd))) + api.Handle("POST /api/security/components/{component}/install", auth.RequireRole("admin", http.HandlerFunc(s.securityInstall))) + api.Handle("POST /api/security/components/{component}/actions/{action}", auth.RequireRole("admin", http.HandlerFunc(s.securityComponentAction))) mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api)))) assets, _ := fs.Sub(web.FS, ".") f := http.FileServer(http.FS(assets)) @@ -149,6 +164,18 @@ func (s *Server) agent(m *http.ServeMux) { a.HandleFunc("POST /agent/v1/docker/containers/{id}/bind-permissions/preview", s.localBindPermissionPreview) a.HandleFunc("POST /agent/v1/host/bind-permissions/repair", s.localRepairBindPermissions) a.HandleFunc("POST /agent/v1/host/users", s.localCreateHostUser) + a.HandleFunc("GET /agent/v1/security/status", s.localSecurityStatus) + a.HandleFunc("GET /agent/v1/security/firewall", s.localSecurityFirewall) + a.HandleFunc("POST /agent/v1/security/firewall/preview", s.localSecurityFirewallPreview) + a.HandleFunc("POST /agent/v1/security/firewall/apply", s.localSecurityFirewallApply) + a.HandleFunc("POST /agent/v1/security/firewall/commit", s.localSecurityFirewallCommit) + a.HandleFunc("POST /agent/v1/security/firewall/rollback", s.localSecurityFirewallRollback) + a.HandleFunc("GET /agent/v1/security/fail2ban", s.localSecurityFail2Ban) + a.HandleFunc("PUT /agent/v1/security/fail2ban", s.localSecurityApplyFail2Ban) + a.HandleFunc("GET /agent/v1/security/auditd", s.localSecurityAuditd) + a.HandleFunc("PUT /agent/v1/security/auditd", s.localSecurityApplyAuditd) + a.HandleFunc("POST /agent/v1/security/components/{component}/install", s.localSecurityInstall) + a.HandleFunc("POST /agent/v1/security/components/{component}/actions/{action}", s.localSecurityComponentAction) a.HandleFunc("GET /agent/v1/stacks", s.localList) a.HandleFunc("GET /agent/v1/stacks/{name}", s.localGet) a.HandleFunc("PUT /agent/v1/stacks/{name}", s.localSave) @@ -558,6 +585,19 @@ func (s *Server) relay(w http.ResponseWriter, r *http.Request, id int64, method, w.WriteHeader(status) _, _ = w.Write(b) } +func (s *Server) relayWithTimeout(w http.ResponseWriter, r *http.Request, id int64, method, path string, body any, timeout time.Duration) { + b, status, e := s.nodes.DoWithTimeout(r.Context(), id, method, path, body, timeout) + if e != nil { + if status == 0 { + status = 502 + } + http.Error(w, e.Error(), status) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(b) +} func (s *Server) dockerInventory(w http.ResponseWriter, r *http.Request) { kind := r.PathValue("kind") if id := nodeID(r); id > 0 { @@ -1384,3 +1424,331 @@ func (s *Server) proxyTerminal(w http.ResponseWriter, r *http.Request, id int64) case <-done: } } + +func (s *Server) securityStatus(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodGet, "/agent/v1/security/status", nil) + return + } + s.localSecurityStatus(w, r) +} + +func (s *Server) localSecurityStatus(w http.ResponseWriter, r *http.Request) { + if s.security == nil { + http.Error(w, "host security service unavailable", http.StatusServiceUnavailable) + return + } + jsonOut(w, http.StatusOK, s.security.Status(r.Context())) +} + +func (s *Server) securityFirewall(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodGet, "/agent/v1/security/firewall", nil) + return + } + s.localSecurityFirewall(w, r) +} + +func (s *Server) localSecurityFirewall(w http.ResponseWriter, r *http.Request) { + if s.security == nil { + http.Error(w, "host security service unavailable", http.StatusServiceUnavailable) + return + } + jsonOut(w, http.StatusOK, s.security.FirewallPolicy()) +} + +func (s *Server) securityFirewallPreview(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.FirewallPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/preview", in) + return + } + s.securityFirewallPreviewLocal(w, r, in) +} +func (s *Server) localSecurityFirewallPreview(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.FirewallPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityFirewallPreviewLocal(w, r, in) +} +func (s *Server) securityFirewallPreviewLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.FirewallPolicy) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.PreviewFirewall(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) securityFirewallApply(w http.ResponseWriter, r *http.Request) { + var in struct { + Policy hostsecurity.FirewallPolicy `json:"policy"` + RollbackSeconds int `json:"rollback_seconds"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/apply", in) + return + } + s.securityFirewallApplyLocal(w, r, in.Policy, in.RollbackSeconds) +} +func (s *Server) localSecurityFirewallApply(w http.ResponseWriter, r *http.Request) { + var in struct { + Policy hostsecurity.FirewallPolicy `json:"policy"` + RollbackSeconds int `json:"rollback_seconds"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityFirewallApplyLocal(w, r, in.Policy, in.RollbackSeconds) +} +func (s *Server) securityFirewallApplyLocal(w http.ResponseWriter, r *http.Request, p hostsecurity.FirewallPolicy, seconds int) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.ApplyFirewall(r.Context(), p, seconds) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) securityFirewallCommit(w http.ResponseWriter, r *http.Request) { + var in struct { + ChangeID string `json:"change_id"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/commit", in) + return + } + s.securityFirewallCommitLocal(w, r, in.ChangeID) +} +func (s *Server) localSecurityFirewallCommit(w http.ResponseWriter, r *http.Request) { + var in struct { + ChangeID string `json:"change_id"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityFirewallCommitLocal(w, r, in.ChangeID) +} +func (s *Server) securityFirewallCommitLocal(w http.ResponseWriter, r *http.Request, id string) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + if e := s.security.CommitFirewall(id); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "message": "Firewall change committed."}) +} + +func (s *Server) securityFirewallRollback(w http.ResponseWriter, r *http.Request) { + var in struct { + ChangeID string `json:"change_id"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPost, "/agent/v1/security/firewall/rollback", in) + return + } + s.securityFirewallRollbackLocal(w, r, in.ChangeID) +} +func (s *Server) localSecurityFirewallRollback(w http.ResponseWriter, r *http.Request) { + var in struct { + ChangeID string `json:"change_id"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityFirewallRollbackLocal(w, r, in.ChangeID) +} +func (s *Server) securityFirewallRollbackLocal(w http.ResponseWriter, r *http.Request, id string) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + if e := s.security.RollbackFirewall(r.Context(), id); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "message": "Firewall change rolled back."}) +} + +func (s *Server) securityFail2Ban(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodGet, "/agent/v1/security/fail2ban", nil) + return + } + s.localSecurityFail2Ban(w, r) +} +func (s *Server) localSecurityFail2Ban(w http.ResponseWriter, r *http.Request) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + jsonOut(w, 200, s.security.Fail2BanPolicy()) +} +func (s *Server) securityApplyFail2Ban(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.Fail2BanPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPut, "/agent/v1/security/fail2ban", in) + return + } + s.securityApplyFail2BanLocal(w, r, in) +} +func (s *Server) localSecurityApplyFail2Ban(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.Fail2BanPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityApplyFail2BanLocal(w, r, in) +} +func (s *Server) securityApplyFail2BanLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.Fail2BanPolicy) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.ApplyFail2Ban(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) securityAuditd(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodGet, "/agent/v1/security/auditd", nil) + return + } + s.localSecurityAuditd(w, r) +} +func (s *Server) localSecurityAuditd(w http.ResponseWriter, r *http.Request) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + jsonOut(w, 200, s.security.AuditdPolicy()) +} +func (s *Server) securityApplyAuditd(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.AuditdPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPut, "/agent/v1/security/auditd", in) + return + } + s.securityApplyAuditdLocal(w, r, in) +} +func (s *Server) localSecurityApplyAuditd(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.AuditdPolicy + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityApplyAuditdLocal(w, r, in) +} +func (s *Server) securityApplyAuditdLocal(w http.ResponseWriter, r *http.Request, in hostsecurity.AuditdPolicy) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.ApplyAuditd(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) securityInstall(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.InstallInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + component := r.PathValue("component") + if id := nodeID(r); id > 0 { + s.relayWithTimeout(w, r, id, http.MethodPost, "/agent/v1/security/components/"+url.PathEscape(component)+"/install", in, 10*time.Minute) + return + } + s.securityInstallLocal(w, r, component, in) +} +func (s *Server) localSecurityInstall(w http.ResponseWriter, r *http.Request) { + var in hostsecurity.InstallInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + s.securityInstallLocal(w, r, r.PathValue("component"), in) +} +func (s *Server) securityInstallLocal(w http.ResponseWriter, r *http.Request, component string, in hostsecurity.InstallInput) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.Install(r.Context(), component, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) securityComponentAction(w http.ResponseWriter, r *http.Request) { + component, action := r.PathValue("component"), r.PathValue("action") + if id := nodeID(r); id > 0 { + s.relay(w, r, id, http.MethodPost, "/agent/v1/security/components/"+url.PathEscape(component)+"/actions/"+url.PathEscape(action), map[string]any{}) + return + } + s.securityComponentActionLocal(w, r, component, action) +} +func (s *Server) localSecurityComponentAction(w http.ResponseWriter, r *http.Request) { + s.securityComponentActionLocal(w, r, r.PathValue("component"), r.PathValue("action")) +} +func (s *Server) securityComponentActionLocal(w http.ResponseWriter, r *http.Request, component, action string) { + if s.security == nil { + http.Error(w, "host security service unavailable", 503) + return + } + v, e := s.security.ComponentAction(r.Context(), component, action) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 62c579b..5d94117 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -14,5 +14,5 @@ func TestRouterPatternsDoNotConflict(t *testing.T) { t.Fatalf("ServeMux route conflict: %v", r) } }() - _ = New(config.Config{Mode: config.ModeStandalone}, &auth.Service{}, nil, nil, nil, (*audit.Service)(nil), nil, nil) + _ = New(config.Config{Mode: config.ModeStandalone}, &auth.Service{}, nil, nil, nil, (*audit.Service)(nil), nil, nil, nil) } diff --git a/internal/nodes/nodes.go b/internal/nodes/nodes.go index 31a4a4b..0d8f594 100644 --- a/internal/nodes/nodes.go +++ b/internal/nodes/nodes.go @@ -155,6 +155,13 @@ func (m *Manager) get(ctx context.Context, id int64) (storedNode, error) { return n, nil } func (m *Manager) Do(ctx context.Context, id int64, method, path string, body any) ([]byte, int, error) { + return m.DoWithTimeout(ctx, id, method, path, body, m.client.Timeout) +} + +// DoWithTimeout performs an authenticated agent request with an operation-specific +// timeout. Long-running host package operations use this rather than weakening +// the normal 30-second control-plane timeout for every request. +func (m *Manager) DoWithTimeout(ctx context.Context, id int64, method, path string, body any, timeout time.Duration) ([]byte, int, error) { n, err := m.get(ctx, id) if err != nil { return nil, 0, err @@ -178,7 +185,9 @@ func (m *Manager) Do(ctx context.Context, id int64, method, path string, body an if body != nil { req.Header.Set("Content-Type", "application/json") } - resp, err := m.client.Do(req) + client := *m.client + client.Timeout = timeout + resp, err := client.Do(req) if err != nil { return nil, 0, err } diff --git a/web/app.js b/web/app.js index 9787e54..f968aaa 100644 --- a/web/app.js +++ b/web/app.js @@ -23,12 +23,12 @@ function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/ function setCrumb(t){$('#crumb').textContent=t} function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote'):'Local Docker'} async function init(){applyPreferences();state.me=await api('/api/me');const [sysR,nodesR]=await Promise.allSettled([api('/api/system'),api('/api/nodes')]);state.system=sysR.status==='fulfilled'?sysR.value:null;state.nodes=nodesR.status==='fulfilled'&&Array.isArray(nodesR.value)?nodesR.value:[];renderUser();renderNodePicker();wireShell();await refreshData(true);navigate('dashboard')} -function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')} +function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')} function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`${state.nodes.map(n=>``).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}} function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)} function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()} async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitors: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Services: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}} -function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,activity:renderActivity}[state.view]||renderDashboard)()} +function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,activity:renderActivity}[state.view]||renderDashboard)()} function pageHead(title,sub,actions=''){return `

${esc(title)}

${esc(sub)}

${actions}
`} function renderDashboard(){setCrumb('Dashboard');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Dashboard',nodeName()+' · Docker & Uptime overview','Ctrl+S speichert Stacks')}
Compose stacks${state.stacks.length}
${running} running
Monitors up${up}
${state.monitors.length} configured
Monitor incidents${down}
current probe failures
Services${svcUp}/${state.services.length}
${svcDown} degraded

Compose stacks

${stackTable(state.stacks.slice(0,8))}

Uptime monitors

${monitorTable(state.monitors.slice(0,8))}
`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))} function stackTable(items){if(!items.length)return'
No compose stacks found.
';return `${items.map(s=>``).join('')}
NameStatusServicesImages
▱${esc(s.name)}
${badge(s.status)}${(s.services||[]).length}${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}
`} @@ -198,6 +198,56 @@ async function renderNotifications(){setCrumb('Observability / Notifications');$ function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`
`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Topic','topic')+f('nToken','Access token','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App token','token',true);return f('nHost','SMTP host','host')+f('nPort','Port','port',false,'587')+`
`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+``} function notificationModal(x=null){const type=x?.type||'webhook';modal(`

${x?'Edit':'Add'} notification provider

${notifyConfigFields(type,x?.config||{})}
`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}} + + +async function renderSecurity(){ + setCrumb(`System / Host Security / ${nodeName()}`); + if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host Security','Administrator access required.')}
Host security configuration is restricted to administrators.
`; return } + $('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'')}
Auditing host security posture…
`; + $('#securityRefresh').onclick=renderSecurity; + try{ + const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{}; + window.__securityCaps=c; + 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()}`,'')} +
HOST SECURITY POSTURE
${Number(d.score||0)}/100

${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}

${securityCapabilityPills(c)}
+ ${c.reason?`
Capability: ${esc(c.reason)}
`:''} +
+ ${securityComponentCard('firewall','Firewall · nftables','Isolated host INPUT policy. Docker NAT/FORWARD chains remain untouched.',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)} +
+

Security findings

Dockwatch-managed posture only; not a substitute for a full host benchmark.
${securityFindings(d.findings||[])}
+

Safety model

Audit first

Inspection can be enabled independently from host mutations.

Explicit mutation opt-in

Configuration and package installation are separate capabilities.

Managed drop-ins

Fail2Ban and auditd use dedicated Dockwatch files; foreign configuration is preserved.

Audited changes

Every mutation passes the existing Dockwatch admin RBAC, origin guard and Activity audit trail.

`; + $('#securityRefresh').onclick=renderSecurity; + $$('[data-secinstall]').forEach(b=>b.onclick=()=>securityInstall(b.dataset.secinstall,b)); + $$('[data-secaction]').forEach(b=>b.onclick=()=>securityComponentAction(b.dataset.seccomponent,b.dataset.secaction,b)); + $('#configure-firewall')?.addEventListener('click',securityFirewallModal); + $('#configure-fail2ban')?.addEventListener('click',securityFail2BanModal); + $('#configure-auditd')?.addEventListener('click',securityAuditdModal); + }catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}
${esc(e.message)}
`} +} +function securityCapabilityPills(c){return `host root ${c.host_root_available?'✓':'×'}host namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'manage':'audit only'}packages ${c.allow_package_management?'enabled':'locked'}`} +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 `
${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

${esc(title)}

${esc(desc)}

${installed?(active?badge('up'):badge('paused')):badge('unknown')}
Installed${installed?'Yes':'No'}Runtime${active?'Active':'Inactive'}Boot${st.enabled?'Enabled':'—'}Config${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}
${st.version?`
${esc(st.version)}
`:''}${st.detail?`
${esc(st.detail)}
`:''}${conflicts?.length?`
Conflict: ${esc(conflicts.join(', '))} active. Dockwatch firewall apply is blocked.
`:''}
${!installed?``:''}${installed?`${key!=='firewall'?``:''}`:''}
`} +function securityFindings(rows){if(!rows.length)return '
No findings.
';return `
${rows.map(f=>`
${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}
${esc(f.title)}

${esc(f.detail)}

${f.action?`${esc(f.action)}`:''}
`).join('')}
`} +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 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(`

Managed nftables firewall

Scope: Dockwatch manages only table inet dockwatch and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.

Port rules

${rules.map(firewallRuleRow).join('')}
Lockout protection: 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.
`);$('#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 `
`} +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)}} +function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`

${esc(title)}

${warnings.map(x=>`
${esc(x)}
`).join('')}${conflicts?.length?`
Conflicts: ${esc(conflicts.join(', '))}
`:''}
${esc(text||'')}
`)} +function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`

Firewall applied · verification window

Do not close this dialog yet. If the new policy breaks access, Dockwatch will restore the previous managed firewall policy automatically.
Automatic rollback in…

Verify SSH and any other management path in a separate session. Then keep the change.

`);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(`

Fail2Ban policy

Dockwatch writes only /etc/fail2ban/jail.d/dockwatch.local. Other distro/user jails are preserved and remain effective.

Jails

${asArray(p.jails).map(fail2banJailRow).join('')}
`);$('#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 `
`} +function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())} +async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}} +function auditdEditor(p){const check=(id,label,key)=>``;modal(`

Linux Audit policy

Dockwatch writes only /etc/audit/rules.d/90-dockwatch.rules and loads it through augenrules. Presets use file watches rather than broad syscall rules.
${check('audIdentity','Identity files (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH server configuration','ssh')}${check('audDocker','Docker socket and daemon configuration','docker')}${check('audSystemd','systemd unit configuration','systemd')}${check('audModules','Kernel module configuration','kernel_modules')}

Custom file watches

${asArray(p.custom).map(auditWatchRow).join('')}
`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Loading…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit rules loaded');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}} +function auditWatchRow(w={}){return `
`} +function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())} + window.addEventListener('resize',terminalResize); function modal(html){$('#modalRoot').innerHTML=`
`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)} function closeModal(){$('#modalRoot').innerHTML=''} diff --git a/web/index.html b/web/index.html index 5ddb5cc..aa8a58c 100644 --- a/web/index.html +++ b/web/index.html @@ -28,6 +28,7 @@ +
v…
U
……
diff --git a/web/styles.css b/web/styles.css index 0c99fb8..66d872c 100644 --- a/web/styles.css +++ b/web/styles.css @@ -20,3 +20,8 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap .serviceProbeList{max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:6px;background:var(--panel2)}.serviceProbeList .switch{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;padding:7px;border-radius:5px}.serviceProbeList .switch:hover{background:var(--panel3)} /* v9.2 host/container identity diagnostics */ .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} +@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}}