This commit is contained in:
2026-09-01 06:31:41 +02:00
parent ad54651558
commit 695ac8a713
14 changed files with 621 additions and 13 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -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
+101 -4
View File
@@ -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/<HOST_SECURITY_HOST_PID>/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
```
+10 -1
View File
@@ -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,
+4
View File
@@ -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
+4
View File
@@ -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
+34
View File
@@ -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")
+21
View File
@@ -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")
}
}
+370 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
+10 -1
View File
@@ -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
}
+52 -2
View File
@@ -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=`<option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).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 `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
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','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Refresh</button>')}<div class="stats"><div class="stat"><small>Compose stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} running</span></div></div><div class="stat"><small>Monitors up</small><strong>${up}</strong><div class="trend">${state.monitors.length} configured</div></div><div class="stat"><small>Monitor incidents</small><strong class="${down?'red':''}">${down}</strong><div class="trend">current probe failures</div></div><div class="stat"><small>Services</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} degraded</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose stacks</h2><button class="btn tiny" id="goStacks">View all</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime monitors</h2><button class="btn tiny" id="goMons">View all</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#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'<div class="empty">No compose stacks found.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Services</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
@@ -198,6 +198,56 @@ async function renderNotifications(){setCrumb('Observability / Notifications');$
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;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')+`<div class="field"><label>Security</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">None</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP authentication</label>`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> Disable TLS certificate verification (unsafe)</label>`}
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} notification provider</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Type</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Enabled</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="nSave">Save</button></div>`);$('#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.')}<div class="empty">Host security configuration is restricted to administrators.</div>`; return }
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}<div class="panel"><div class="empty">Auditing host security posture…</div></div>`;
$('#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()}`,'<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('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>
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Security findings</h2><span class="muted">Dockwatch-managed posture only; not a substitute for a full host benchmark.</span></div>${securityFindings(d.findings||[])}</div>
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Safety model</h2></div><div class="securitySafety"><div><b>Audit first</b><p>Inspection can be enabled independently from host mutations.</p></div><div><b>Explicit mutation opt-in</b><p>Configuration and package installation are separate capabilities.</p></div><div><b>Managed drop-ins</b><p>Fail2Ban and auditd use dedicated Dockwatch files; foreign configuration is preserved.</p></div><div><b>Audited changes</b><p>Every mutation passes the existing Dockwatch admin RBAC, origin guard and Activity audit trail.</p></div></div></div>`;
$('#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())}<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 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 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)}}
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)}}}
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>`}
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)=>`<label class="switch"><input id="${id}" type="checkbox" ${p[key]?'checked':''}> ${label}</label>`;modal(`<div class="modalhead"><h2>Linux Audit policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/audit/rules.d/90-dockwatch.rules</code> and loads it through <code>augenrules</code>. Presets use file watches rather than broad syscall rules.</div><div class="securityChecks" style="margin-top:12px">${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')}</div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Custom file watches</h3><button class="btn tiny" id="audAdd">+ Watch</button></div><div id="audWatches">${asArray(p.custom).map(auditWatchRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="audSave">Validate & load</button></div>`);$('#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 `<div class="audWatch securityFormRow audit"><input class="audPath" placeholder="/srv/app/config" value="${esc(w.path||'')}"><input class="audPerms" placeholder="wa" value="${esc(w.permissions||'wa')}"><input class="audKey" placeholder="audit-key" value="${esc(w.key||'')}"><button class="iconbtn audRemove">×</button></div>`}
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
window.addEventListener('resize',terminalResize);
function modal(html){$('#modalRoot').innerHTML=`<div class="modalback"><div class="modal" role="dialog" aria-modal="true">${html}</div></div>`;$$('[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=''}
+1
View File
@@ -28,6 +28,7 @@
<button data-view="notifications"><span>⌁</span>Notifications</button>
<div class="navlabel">System</div>
<button data-view="nodes"><span>◎</span>Environments</button>
<button data-view="security"><span>⛨</span>Host Security</button>
<button data-view="activity"><span>≋</span>Activity</button>
</nav>
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Logout">↪</button></div>
+5
View File
@@ -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}}