diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cc3ac0f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +*.zip +dockwatch +data/ +stacks/ +.env +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b25df55 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +APP_MODE=standalone +LISTEN_ADDR=:8080 +BASE_URL=http://localhost:8080 +DATA_DIR=/data +STACKS_DIR=/stacks +AUTH_DISABLED=true +APP_SECRET=change-me-change-me-change-me-32bytes +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_ADMIN_GROUP=dockwatch-admins +OIDC_OPERATOR_GROUP=dockwatch-operators +AGENT_TOKEN= +CHECK_CONCURRENCY=8 +CHECK_RETENTION_DAYS=30 +HTTP_TIMEOUT_SECONDS=10 +AUDIT_RETENTION_DAYS=180 diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ca567e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +data/ +stacks/ +*.db +*.db-shm +*.db-wal +dockwatch diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f6a995a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1.7 +FROM golang:1.25-alpine AS build +WORKDIR /src +ARG VERSION=dev +ARG COMMIT=dev +ARG BUILD_DATE=unknown +COPY go.mod ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=linux go build -trimpath \ + -ldflags="-s -w -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Version=${VERSION} -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Commit=${COMMIT} -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Date=${BUILD_DATE}" \ + -o /out/dockwatch ./cmd/dockwatch + +FROM docker:cli +ENV DOCKER_CONFIG=/data/docker-config +RUN apk add --no-cache ca-certificates tzdata git openssh-client +COPY --from=build /out/dockwatch /usr/local/bin/dockwatch +VOLUME ["/data","/stacks"] +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/dockwatch"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cab53a7 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +.PHONY: fmt test build docker run clean +VERSION ?= dev +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Version=$(VERSION) -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Commit=$(COMMIT) -X git.send.nrw/sendnrw/dockwatch/internal/buildinfo.Date=$(BUILD_DATE) + +fmt: + gofmt -w $$(find . -name '*.go' -type f) +test: + go test ./... +build: + CGO_ENABLED=0 go build -trimpath -ldflags='$(LDFLAGS)' -o dockwatch ./cmd/dockwatch +docker: + docker build --build-arg VERSION='$(VERSION)' --build-arg COMMIT='$(COMMIT)' --build-arg BUILD_DATE='$(BUILD_DATE)' -t dockwatch:local . +run: + docker compose up -d --build +clean: + rm -f dockwatch diff --git a/README.md b/README.md index 20dcfc1..c14d4a8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,328 @@ -# dockwatch +## v9.1 packaging fix + +v9.1 fixes the v9 source archive packaging. The v9 ZIP accidentally omitted `internal/stacks/`, which could leave an older local copy of that package in place when extracting over an existing checkout and cause method-signature build errors. v9.1 is a complete source archive and includes `internal/stacks/stacks.go` and its tests. Always extract it into a fresh directory. +# Dockwatch v9.1 + +> Go module: `git.send.nrw/sendnrw/dockwatch` + +Dockwatch is a single-binary Go control plane for Docker Compose, Docker resources and uptime monitoring. It combines Dockge/Dockhand-style operational workflows with Uptime-Kuma-style monitoring while keeping its own implementation and UI. + +The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`. + +## What is included + +### Compose / stack management + +- multiple Compose stacks per environment +- local Docker plus remote agents +- staged `docker compose config --quiet` validation before a save +- atomic managed-file writes and rollback on save failure +- full bidirectional Compose designer backed by a YAML AST +- recursive editing of all YAML maps/arrays/scalars, including unknown/future Compose fields +- `compose.yaml`, root `.env`, `envs/`, `secrets/` and `configs/` +- Up / Start / Stop / Restart / Down / Pull / Update / Force recreate +- service/container status +- live SSE logs with pause, follow, filter and download +- interactive PTY/WebSocket `docker compose exec` terminal +- normalized service/dependency/network/volume graph from `docker compose config --format json` +- registry digest based image-update checks +- safe stack deletion: only Dockwatch-managed definition files are removed by default +- explicit, typed-name confirmation for full stack-folder purge + +The editor keeps unsaved drafts in browser storage. Background refresh never overwrites a dirty editor. + +### Git-backed stacks + +Git sources support: + +- local or remote-agent environment +- repository, branch, workdir and Compose filename +- manual sync +- optional deploy after sync +- signed webhooks (`X-Hub-Signature-256`, `X-Gitlab-Token` or `X-Webhook-Token`) +- encrypted webhook secret at rest +- last commit / last sync / last error + +A Git sync is now **managed-file based**. Dockwatch stores a small `.dockwatch/git-manifest.json` in the stack directory and only replaces/removes files that belong to the Git checkout. Unrelated runtime or bind-mount data beside the Compose project is preserved. Syncs of the same source are serialized and a failed apply rolls back the touched file set. + +Symlink stack destinations and symlink paths inside Git-managed writes are rejected. + +### Docker resources + +**Containers** + +- list/filter +- start / stop / restart / force-remove +- operator-only inspect +- one-shot CPU/memory/network/block stats + +**Images** + +- list/filter +- pull / remove / prune +- registry login/logout +- persistent Docker auth via `DOCKER_CONFIG=/data/docker-config` + +**Volumes** + +- list/filter +- create / remove / prune +- driver and labels + +**Networks** + +- list/filter +- create / remove / prune +- driver, labels, `internal`, `attachable` + +Docker positional arguments and Compose service names are validated before invoking the CLI so option-like values cannot be interpreted as Docker CLI flags. + +### Monitoring + +Probe types: + +- HTTP(S) +- TCP +- DNS +- Docker container running state +- Docker container running + optional `healthy` requirement + +Monitor lifecycle: + +- create / edit / delete +- pause / resume +- manual or timed maintenance +- maintenance notes +- manual **Check now** +- interval and timeout +- HTTP method and accepted status range +- request headers/body +- keyword / inverted keyword assertion +- optional HTTP TLS verification bypass +- local or remote-agent execution +- heartbeat history +- last latency/status message +- 24-hour uptime +- retention cleanup + +The scheduler uses a lightweight monitor query and does not recompute 24-hour uptime statistics every two seconds. Manual checks are serialized with scheduled checks and do not accidentally resume paused/maintenance monitors. Shutdown-cancelled checks are not persisted as false outages. + +### Services and public status pages + +Probes can be grouped into user-facing Services. Aggregation is strict: + +- any probe `down` -> Service `down` +- otherwise maintenance -> Service `maintenance` +- pending/paused states are retained +- otherwise Service `up` + +Service dialogs can assign/unassign probes directly. + +Admins can publish selected Services on public pages: + +```text +/status/ +/public/api/status/ +``` + +The public JSON API uses a dedicated DTO and exposes only public status information (name, status, uptime, latency and last-check timestamp), not targets, node IDs, request headers, bodies or credentials. HTML/JSON status responses are not cached. + +### Notifications + +Providers: + +- generic JSON webhook +- ntfy +- Gotify +- SMTP + +SMTP supports: + +- `STARTTLS` +- implicit `SSL/TLS` +- `None` +- authentication on/off independently of TLS +- optional certificate-verification bypass for private/self-signed infrastructure + +SMTP connections inherit the notification deadline after connect as well, so a stalled server cannot leave a delivery goroutine hanging indefinitely. SMTP/password/token/secret values are AES-GCM encrypted at rest. Notification configuration is admin-only in both API and UI. + +### PocketID / OIDC and roles + +Roles are mapped from OIDC/PocketID groups: + +- `viewer` – dashboards/status/inventory, sanitized monitor configuration +- `operator` – stack configuration/logs/terminal, Docker actions, monitor/service operations, Git stacks +- `admin` – environments, notifications, status-page administration and audit log + +Viewer API responses do not expose monitor request headers/body/keywords. Stack definition detail and live logs require Operator access because `.env`, secrets and runtime logs may contain sensitive data. + +Required OIDC setup: + +```env +AUTH_DISABLED=false +BASE_URL=https://dockwatch.example.com +OIDC_ISSUER=https://id.example.com +OIDC_CLIENT_ID=... +OIDC_CLIENT_SECRET=... +APP_SECRET=replace-with-at-least-32-random-characters +OIDC_ADMIN_GROUP=dockwatch-admins +OIDC_OPERATOR_GROUP=dockwatch-operators +``` + +Redirect URI: + +```text +https://dockwatch.example.com/auth/callback +``` + +OIDC discovery, token exchange and verification are bounded by `HTTP_TIMEOUT_SECONDS`. Session/state cookies are `HttpOnly`, `SameSite=Lax` and become `Secure` when `BASE_URL` uses HTTPS. + +### Master / agent + +Modes: + +```text +APP_MODE=standalone +APP_MODE=master +APP_MODE=agent +``` + +An agent exposes only `/agent/v1/*` and requires an `AGENT_TOKEN` of at least 24 characters. Agent tokens are AES-GCM encrypted in the master's SQLite database. + +Remote-capable features include: + +- stacks and actions +- logs and PTY terminal +- Compose graph +- image update checks +- containers/images/networks/volumes +- monitoring probes +- Git clone/sync/deploy + +See `examples/compose-master.yml` and `examples/compose-agent.yml`. + +## Full Compose designer + +The Compose tab uses two synchronized representations: + +1. the raw YAML source of truth +2. a recursive visual designer + +YAML changes are parsed after a short debounce. Visual changes patch the selected YAML AST path instead of reserializing a simplified Compose model. Unknown keys, `x-*` extensions and comments are therefore preserved where possible. Invalid/incomplete YAML pauses visual synchronization and never overwrites the source editor. + +The designer is not limited to a hard-coded subset: arbitrary maps, arrays and scalar types remain editable, including current/future Compose fields and long syntax objects. + +Server-side save validation still uses Docker Compose itself after all related `.env`, secret, env-file and config files have been staged. + +## Reliability and security work in v9 + +The v9 review includes, among other changes: + +- fixed fresh-database migration ordering before monitor indexes are created +- SQLite WAL/busy-timeout/foreign-key configuration with a small connection pool +- strict environment parsing instead of silently accepting invalid numeric/boolean values +- graceful HTTP shutdown and sensible server timeouts +- security headers and same-origin checks for browser mutations +- request JSON size limit and single-value decoding +- admin-only audit activity and notification configuration +- audit retention cleanup +- public-status DTO that does not leak internal monitor configuration +- safe IP parsing for audit entries +- sensitive viewer responses reduced +- Compose save snapshot/rollback +- safe stack delete vs. explicit purge +- symlink protection on stack/Git file paths +- atomic managed-file writes +- Git sync serialization and managed-file manifest +- Git copy file-descriptor leak fixed +- Node URL validation rejects credentials/query/fragment +- remote terminal WebSocket URL/query handling fixed +- registry/Docker CLI positional-argument validation +- scheduler query and shutdown behaviour improved +- defensive frontend collection handling (`null` cannot break list pages) +- admin-only navigation entries hidden for non-admin users +- persisted dark/light theme and collapsible/mobile sidebar +- API connectivity indicator and manual refresh +- denser Docker/monitor/service tables and better empty/error states + +## Configuration + +`.env.example` contains the defaults. Important values: + +```env +APP_MODE=standalone +LISTEN_ADDR=:8080 +BASE_URL=http://localhost:8080 +DATA_DIR=/data +STACKS_DIR=/stacks +AUTH_DISABLED=true +APP_SECRET=change-me-change-me-change-me-32bytes +CHECK_CONCURRENCY=8 +CHECK_RETENTION_DAYS=30 +HTTP_TIMEOUT_SECONDS=10 +AUDIT_RETENTION_DAYS=180 +``` + +`AUTH_DISABLED=true` is for local development only. Do not expose that configuration publicly. + +## Run with Docker Compose + +```bash +cp .env.example .env +docker compose up -d --build +``` + +Runtime mounts normally include: + +```text +/data SQLite, Docker registry config +/stacks Compose projects +/var/run/docker.sock Docker Engine access +``` + +Giving Dockwatch access to the Docker socket grants highly privileged control of that Docker host. Protect the UI and agent endpoint accordingly. + +## Build from source + +The pinned OIDC/OAuth2 releases require **Go 1.25**. The Docker build uses `golang:1.25-alpine`. + +```bash +go mod tidy +go test ./... +CGO_ENABLED=0 go build ./cmd/dockwatch +``` + +Build metadata can be injected through the supplied Makefile/Dockerfile (`VERSION`, `COMMIT`, `BUILD_DATE`) and is visible in `/healthz` and the UI. + +## Database + +SQLite database: + +```text +/data/dockwatch.db +``` + +`modernc.org/sqlite` is used, so no SQLite CGO binding is required. Migrations are additive and include users/sessions, nodes, monitors/checks/services, public status pages, audit events, notification channels and Git sources. + +## Verification for this archive + +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: + +```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 + +go vet ./... +go test -race ./internal/config ./internal/gitops ./internal/httpapi ./internal/monitor ./internal/nodes ./internal/notify ./internal/stacks +node --check web/app.js +``` + +The SQLite migration SQL is additionally smoke-tested against Python's SQLite engine because the temporary `modernc.org/sqlite` stub does not implement a real SQL driver. + +On a normal networked development machine or during `docker build`, run `go mod tidy && go test ./...` once against the real pinned dependencies. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..44f6793 --- /dev/null +++ b/compose.yml @@ -0,0 +1,23 @@ +services: + dockwatch: + build: . + restart: unless-stopped + ports: ["8080:8080"] + environment: + APP_MODE: standalone + BASE_URL: "${BASE_URL:-http://localhost:8080}" + AUTH_DISABLED: "${AUTH_DISABLED:-true}" + APP_SECRET: "${APP_SECRET:-change-this-development-secret-please-123456}" + OIDC_ISSUER: "${OIDC_ISSUER:-}" + OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" + OIDC_ADMIN_GROUP: "${OIDC_ADMIN_GROUP:-dockwatch-admins}" + OIDC_OPERATOR_GROUP: "${OIDC_OPERATOR_GROUP:-dockwatch-operators}" + CHECK_CONCURRENCY: "${CHECK_CONCURRENCY:-8}" + CHECK_RETENTION_DAYS: "${CHECK_RETENTION_DAYS:-30}" + HTTP_TIMEOUT_SECONDS: "${HTTP_TIMEOUT_SECONDS:-10}" + AUDIT_RETENTION_DAYS: "${AUDIT_RETENTION_DAYS:-180}" + volumes: + - ./data:/data + - ./stacks:/stacks + - /var/run/docker.sock:/var/run/docker.sock diff --git a/examples/compose-agent.yml b/examples/compose-agent.yml new file mode 100644 index 0000000..99e3db1 --- /dev/null +++ b/examples/compose-agent.yml @@ -0,0 +1,13 @@ +services: + dockwatch-agent: + image: dockwatch:local + restart: unless-stopped + ports: ["127.0.0.1:8080:8080"] + environment: + APP_MODE: agent + AGENT_TOKEN: "replace-with-a-random-token-at-least-24-characters" + HTTP_TIMEOUT_SECONDS: "10" + volumes: + - ./agent-data:/data + - ./agent-stacks:/stacks + - /var/run/docker.sock:/var/run/docker.sock diff --git a/examples/compose-master.yml b/examples/compose-master.yml new file mode 100644 index 0000000..f48f9d7 --- /dev/null +++ b/examples/compose-master.yml @@ -0,0 +1,23 @@ +services: + dockwatch-master: + image: dockwatch:local + restart: unless-stopped + ports: ["8080:8080"] + environment: + APP_MODE: master + BASE_URL: "https://dockwatch.example.com" + APP_SECRET: "replace-with-at-least-32-random-characters" + AUTH_DISABLED: "false" + OIDC_ISSUER: "https://id.example.com" + OIDC_CLIENT_ID: "replace-me" + OIDC_CLIENT_SECRET: "replace-me" + OIDC_ADMIN_GROUP: "dockwatch-admins" + OIDC_OPERATOR_GROUP: "dockwatch-operators" + CHECK_CONCURRENCY: "8" + CHECK_RETENTION_DAYS: "30" + HTTP_TIMEOUT_SECONDS: "10" + AUDIT_RETENTION_DAYS: "180" + volumes: + - ./master-data:/data + - ./master-stacks:/stacks + - /var/run/docker.sock:/var/run/docker.sock diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d20a3d4 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.send.nrw/sendnrw/dockwatch + +go 1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 + golang.org/x/oauth2 v0.36.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..46bed80 --- /dev/null +++ b/go.sum @@ -0,0 +1,64 @@ +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..5817a52 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,99 @@ +package audit + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" +) + +type Entry struct { + ID int64 `json:"id"` + UserID *int64 `json:"user_id,omitempty"` + Actor string `json:"actor"` + Action string `json:"action"` + Resource string `json:"resource"` + Detail map[string]any `json:"detail,omitempty"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Status int `json:"status"` + CreatedAt int64 `json:"created_at"` +} + +type Service struct{ db *sql.DB } + +func New(db *sql.DB) *Service { return &Service{db: db} } + +func (s *Service) Log(ctx context.Context, e Entry) error { + if e.CreatedAt == 0 { + e.CreatedAt = time.Now().Unix() + } + b, _ := json.Marshal(e.Detail) + _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log(user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, e.UserID, e.Actor, e.Action, e.Resource, string(b), e.IP, e.UserAgent, e.Status, e.CreatedAt) + return err +} +func (s *Service) List(ctx context.Context, limit, offset int, action string) ([]Entry, error) { + if limit < 1 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + if offset < 0 { + offset = 0 + } + q := `SELECT id,user_id,actor,action,resource,detail_json,ip,user_agent,status,created_at FROM audit_log` + args := []any{} + if strings.TrimSpace(action) != "" { + q += ` WHERE action LIKE ?` + args = append(args, "%"+strings.TrimSpace(action)+"%") + } + q += ` ORDER BY id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Entry{} + for rows.Next() { + var e Entry + var uid sql.NullInt64 + var raw string + if err := rows.Scan(&e.ID, &uid, &e.Actor, &e.Action, &e.Resource, &raw, &e.IP, &e.UserAgent, &e.Status, &e.CreatedAt); err != nil { + return nil, err + } + if uid.Valid { + v := uid.Int64 + e.UserID = &v + } + _ = json.Unmarshal([]byte(raw), &e.Detail) + out = append(out, e) + } + return out, rows.Err() +} + +// Run periodically prunes old audit records. A retention of 0 keeps the audit +// trail indefinitely. +func (s *Service) Run(ctx context.Context, retentionDays int) { + if retentionDays <= 0 { + <-ctx.Done() + return + } + cleanup := func() { + cut := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour).Unix() + _, _ = s.db.ExecContext(ctx, `DELETE FROM audit_log WHERE created_at?`, h[:], time.Now().Unix()).Scan(&u.ID, &u.Sub, &u.Email, &u.Name, &u.Role) + if e != nil { + http.Error(w, "unauthorized", 401) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, u))) + }) +} +func UserFrom(ctx context.Context) (User, bool) { u, ok := ctx.Value(userKey).(User); return u, ok } +func RequireRole(min string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := UserFrom(r.Context()) + if !ok || rank(u.Role) < rank(min) { + http.Error(w, "forbidden", 403) + return + } + next.ServeHTTP(w, r) + }) +} +func rank(r string) int { + switch strings.ToLower(r) { + case "admin": + return 3 + case "operator": + return 2 + default: + return 1 + } +} +func token(n int) (string, error) { + b := make([]byte, n) + _, e := rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b), e +} +func (s *Service) temp(w http.ResponseWriter, n, v string) { + http.SetCookie(w, &http.Cookie{Name: n, Value: v, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: 600}) +} +func (s *Service) clearTemp(w http.ResponseWriter, n string) { + http.SetCookie(w, &http.Cookie{Name: n, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: -1}) +} diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 0000000..a0ab114 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,21 @@ +package buildinfo + +import "runtime" + +// Values may be overridden at build time with -ldflags -X. +var ( + Version = "0.9.0" + Commit = "dev" + Date = "unknown" +) + +type Info struct { + Version string `json:"version"` + Commit string `json:"commit"` + BuildDate string `json:"build_date"` + GoVersion string `json:"go_version"` +} + +func Current() Info { + return Info{Version: Version, Commit: Commit, BuildDate: Date, GoVersion: runtime.Version()} +} diff --git a/internal/composeedit/composeedit.go b/internal/composeedit/composeedit.go new file mode 100644 index 0000000..3611d1f --- /dev/null +++ b/internal/composeedit/composeedit.go @@ -0,0 +1,215 @@ +package composeedit + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +type ParseResult struct { + Value any `json:"value"` +} + +type Patch struct { + Path []string `json:"path"` + Value any `json:"value"` + Delete bool `json:"delete"` +} + +func Parse(src string) (ParseResult, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(src), &doc); err != nil { + return ParseResult{}, fmt.Errorf("yaml: %w", err) + } + if len(doc.Content) == 0 { + return ParseResult{Value: map[string]any{}}, nil + } + var v any + if err := doc.Content[0].Decode(&v); err != nil { + return ParseResult{}, err + } + v = normalize(v) + return ParseResult{Value: v}, nil +} + +func Apply(src string, p Patch) (string, error) { + if len(p.Path) == 0 { + return "", errors.New("path is required") + } + var doc yaml.Node + if err := yaml.Unmarshal([]byte(src), &doc); err != nil { + return "", fmt.Errorf("yaml: %w", err) + } + if len(doc.Content) == 0 { + return "", errors.New("empty yaml document") + } + root := doc.Content[0] + parent, last, err := walkParent(root, p.Path, !p.Delete) + if err != nil { + return "", err + } + if p.Delete { + if err := deleteChild(parent, last); err != nil { + return "", err + } + } else { + n, err := valueNode(p.Value) + if err != nil { + return "", err + } + if err := setChild(parent, last, n); err != nil { + return "", err + } + } + var b bytes.Buffer + enc := yaml.NewEncoder(&b) + enc.SetIndent(2) + if err := enc.Encode(&doc); err != nil { + return "", err + } + _ = enc.Close() + return strings.TrimSuffix(b.String(), "\n") + "\n", nil +} + +func normalize(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, v := range x { + out[k] = normalize(v) + } + return out + case map[any]any: + out := map[string]any{} + for k, v := range x { + out[fmt.Sprint(k)] = normalize(v) + } + return out + case []any: + out := make([]any, len(x)) + for i, v := range x { + out[i] = normalize(v) + } + return out + default: + return x + } +} + +func walkParent(root *yaml.Node, path []string, create bool) (*yaml.Node, string, error) { + cur := root + for _, seg := range path[:len(path)-1] { + if cur.Kind == yaml.DocumentNode && len(cur.Content) > 0 { + cur = cur.Content[0] + } + switch cur.Kind { + case yaml.MappingNode: + n := mapGet(cur, seg) + if n == nil { + if !create { + return nil, "", fmt.Errorf("path %q not found", seg) + } + n = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + mapSet(cur, seg, n) + } + cur = n + case yaml.SequenceNode: + i, err := strconv.Atoi(seg) + if err != nil || i < 0 || i >= len(cur.Content) { + return nil, "", fmt.Errorf("invalid array index %q", seg) + } + cur = cur.Content[i] + default: + return nil, "", fmt.Errorf("cannot descend through scalar at %q", seg) + } + } + return cur, path[len(path)-1], nil +} +func mapGet(m *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} +func mapSet(m *yaml.Node, key string, v *yaml.Node) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + old := m.Content[i+1] + v.HeadComment = old.HeadComment + v.LineComment = old.LineComment + v.FootComment = old.FootComment + m.Content[i+1] = v + return + } + } + m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, v) +} +func setChild(p *yaml.Node, key string, v *yaml.Node) error { + switch p.Kind { + case yaml.MappingNode: + mapSet(p, key, v) + return nil + case yaml.SequenceNode: + if key == "-" { + p.Content = append(p.Content, v) + return nil + } + i, err := strconv.Atoi(key) + if err != nil || i < 0 || i > len(p.Content) { + return fmt.Errorf("invalid array index %q", key) + } + if i == len(p.Content) { + p.Content = append(p.Content, v) + } else { + old := p.Content[i] + v.HeadComment, v.LineComment, v.FootComment = old.HeadComment, old.LineComment, old.FootComment + p.Content[i] = v + } + return nil + default: + return errors.New("parent is not a map or array") + } +} +func deleteChild(p *yaml.Node, key string) error { + switch p.Kind { + case yaml.MappingNode: + for i := 0; i+1 < len(p.Content); i += 2 { + if p.Content[i].Value == key { + p.Content = append(p.Content[:i], p.Content[i+2:]...) + return nil + } + } + return nil + case yaml.SequenceNode: + i, err := strconv.Atoi(key) + if err != nil || i < 0 || i >= len(p.Content) { + return fmt.Errorf("invalid array index %q", key) + } + p.Content = append(p.Content[:i], p.Content[i+1:]...) + return nil + default: + return errors.New("parent is not a map or array") + } +} +func valueNode(v any) (*yaml.Node, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var x any + if err := json.Unmarshal(raw, &x); err != nil { + return nil, err + } + var n yaml.Node + if err := n.Encode(x); err != nil { + return nil, err + } + return &n, nil +} diff --git a/internal/composeedit/composeedit_test.go b/internal/composeedit/composeedit_test.go new file mode 100644 index 0000000..6423f98 --- /dev/null +++ b/internal/composeedit/composeedit_test.go @@ -0,0 +1,31 @@ +package composeedit + +import ( + "strings" + "testing" +) + +func TestPatchPreservesUnknownAndComments(t *testing.T) { + src := "# top\nservices:\n app:\n image: nginx:old # keep\n x-future:\n magic: true\n deploy:\n replicas: 2\n" + out, e := Apply(src, Patch{Path: []string{"services", "app", "image"}, Value: "nginx:new"}) + if e != nil { + t.Fatal(e) + } + if !strings.Contains(out, "x-future:") || !strings.Contains(out, "magic: true") || !strings.Contains(out, "replicas: 2") || !strings.Contains(out, "# top") { + t.Fatalf("preservation failed:\n%s", out) + } + r, e := Parse(out) + if e != nil || r.Value == nil { + t.Fatalf("parse %v", e) + } +} +func TestArrayPatch(t *testing.T) { + src := "services:\n app:\n ports:\n - 8080:80\n" + out, e := Apply(src, Patch{Path: []string{"services", "app", "ports", "0"}, Value: "9090:80"}) + if e != nil { + t.Fatal(e) + } + if !strings.Contains(out, "9090:80") { + t.Fatal(out) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..64adf67 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,148 @@ +package config + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Mode string + +const ( + ModeStandalone Mode = "standalone" + ModeMaster Mode = "master" + ModeAgent Mode = "agent" +) + +type Config struct { + Mode Mode + ListenAddr, BaseURL, DataDir, StacksDir, AppSecret string + SecureCookies, AuthDisabled bool + OIDCIssuer, OIDCClientID, OIDCClientSecret, OIDCRedirectURL, OIDCAdminGroup, OIDCOperatorGroup string + AgentToken string + CheckConcurrency, RetentionDays, AuditRetentionDays int + HTTPTimeout time.Duration +} + +func Load() (Config, error) { + checkConcurrency, err := envIntStrict("CHECK_CONCURRENCY", 8) + if err != nil { + return Config{}, err + } + retentionDays, err := envIntStrict("CHECK_RETENTION_DAYS", 30) + if err != nil { + return Config{}, err + } + auditRetentionDays, err := envIntStrict("AUDIT_RETENTION_DAYS", 180) + if err != nil { + return Config{}, err + } + httpTimeoutSeconds, err := envIntStrict("HTTP_TIMEOUT_SECONDS", 10) + if err != nil { + return Config{}, err + } + authDisabled, err := envBoolStrict("AUTH_DISABLED", false) + if err != nil { + return Config{}, err + } + c := Config{ + Mode: Mode(env("APP_MODE", "standalone")), + ListenAddr: env("LISTEN_ADDR", ":8080"), + BaseURL: strings.TrimRight(env("BASE_URL", "http://localhost:8080"), "/"), + DataDir: env("DATA_DIR", "/data"), + StacksDir: env("STACKS_DIR", "/stacks"), + AppSecret: os.Getenv("APP_SECRET"), + AuthDisabled: authDisabled, + OIDCIssuer: strings.TrimRight(os.Getenv("OIDC_ISSUER"), "/"), + OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), + OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), + OIDCAdminGroup: env("OIDC_ADMIN_GROUP", "dockwatch-admins"), + OIDCOperatorGroup: env("OIDC_OPERATOR_GROUP", "dockwatch-operators"), + AgentToken: os.Getenv("AGENT_TOKEN"), + CheckConcurrency: checkConcurrency, + RetentionDays: retentionDays, + AuditRetentionDays: auditRetentionDays, + HTTPTimeout: time.Duration(httpTimeoutSeconds) * time.Second, + } + c.SecureCookies = strings.HasPrefix(c.BaseURL, "https://") + if c.OIDCRedirectURL == "" { + c.OIDCRedirectURL = c.BaseURL + "/auth/callback" + } + switch c.Mode { + case ModeStandalone, ModeMaster, ModeAgent: + default: + return c, fmt.Errorf("APP_MODE must be standalone, master, or agent") + } + if c.CheckConcurrency < 1 || c.CheckConcurrency > 128 { + return c, fmt.Errorf("CHECK_CONCURRENCY must be between 1 and 128") + } + if c.RetentionDays < 0 || c.RetentionDays > 3650 { + return c, fmt.Errorf("CHECK_RETENTION_DAYS must be between 0 and 3650") + } + if c.AuditRetentionDays < 0 || c.AuditRetentionDays > 3650 { + return c, fmt.Errorf("AUDIT_RETENTION_DAYS must be between 0 and 3650") + } + if c.HTTPTimeout < time.Second || c.HTTPTimeout > 5*time.Minute { + return c, fmt.Errorf("HTTP_TIMEOUT_SECONDS must be between 1 and 300") + } + if c.Mode != ModeAgent { + u, err := url.Parse(c.BaseURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return c, errors.New("BASE_URL must be an absolute http(s) URL without credentials, query or fragment") + } + } + if c.Mode == ModeAgent { + if len(c.AgentToken) < 24 { + return c, errors.New("AGENT_TOKEN must be at least 24 characters in agent mode") + } + return c, nil + } + if len(c.AppSecret) < 32 { + return c, errors.New("APP_SECRET must be at least 32 characters") + } + if !c.AuthDisabled && (c.OIDCIssuer == "" || c.OIDCClientID == "" || c.OIDCClientSecret == "") { + return c, errors.New("OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are required unless AUTH_DISABLED=true") + } + return c, nil +} +func (c Config) DBPath() string { return c.DataDir + "/dockwatch.db" } +func (c Config) EncryptionKey() []byte { s := sha256.Sum256([]byte(c.AppSecret)); return s[:] } +func (c Config) SecretFingerprint() string { + h := sha256.Sum256([]byte(c.AppSecret)) + return base64.RawURLEncoding.EncodeToString(h[:6]) +} +func env(k, f string) string { + if v := os.Getenv(k); v != "" { + return v + } + return f +} +func envIntStrict(k string, f int) (int, error) { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return f, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s must be an integer: %w", k, err) + } + return n, nil +} +func envBoolStrict(k string, f bool) (bool, error) { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return f, nil + } + b, err := strconv.ParseBool(v) + if err != nil { + return false, fmt.Errorf("%s must be a boolean: %w", k, err) + } + return b, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f655a6b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,18 @@ +package config + +import "testing" + +func TestStrictEnvironmentParsing(t *testing.T) { + t.Setenv("CHECK_CONCURRENCY", "not-a-number") + if _, err := Load(); err == nil { + t.Fatal("expected invalid CHECK_CONCURRENCY to fail instead of silently using a default") + } +} + +func TestStrictBooleanParsing(t *testing.T) { + t.Setenv("CHECK_CONCURRENCY", "8") + t.Setenv("AUTH_DISABLED", "sometimes") + if _, err := Load(); err == nil { + t.Fatal("expected invalid AUTH_DISABLED to fail") + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..a9b901e --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,132 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + _ "modernc.org/sqlite" + "os" + "path/filepath" + "time" +) + +func Open(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return nil, err + } + dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // WAL allows concurrent readers while SQLite still serializes writes. A small + // pool keeps UI reads responsive while monitor checks are being persisted. + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(4) + db.SetConnMaxLifetime(30 * time.Minute) + ctx, c := context.WithTimeout(context.Background(), 10*time.Second) + defer c() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, err + } + if err := migrate(ctx, db); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} +func migrate(ctx context.Context, db *sql.DB) error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT,oidc_sub TEXT NOT NULL UNIQUE,email TEXT NOT NULL DEFAULT '',name TEXT NOT NULL DEFAULT '',role TEXT NOT NULL DEFAULT 'viewer',last_login_at INTEGER NOT NULL,created_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS sessions(token_hash BLOB PRIMARY KEY,user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at INTEGER NOT NULL,created_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`, + `CREATE TABLE IF NOT EXISTS nodes(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL UNIQUE,base_url TEXT NOT NULL,token_enc BLOB NOT NULL,enabled INTEGER NOT NULL DEFAULT 1,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS monitor_services(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL UNIQUE,description TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS monitors(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL,type TEXT NOT NULL,target TEXT NOT NULL,node_id INTEGER NULL REFERENCES nodes(id) ON DELETE SET NULL,service_id INTEGER NULL REFERENCES monitor_services(id) ON DELETE SET NULL,interval_seconds INTEGER NOT NULL DEFAULT 60,timeout_ms INTEGER NOT NULL DEFAULT 5000,expected_min INTEGER NOT NULL DEFAULT 200,expected_max INTEGER NOT NULL DEFAULT 399,method TEXT NOT NULL DEFAULT 'GET',headers_json TEXT NOT NULL DEFAULT '{}',body TEXT NOT NULL DEFAULT '',keyword TEXT NOT NULL DEFAULT '',invert_keyword INTEGER NOT NULL DEFAULT 0,ignore_tls INTEGER NOT NULL DEFAULT 0,require_healthy INTEGER NOT NULL DEFAULT 0,enabled INTEGER NOT NULL DEFAULT 1,status TEXT NOT NULL DEFAULT 'pending',maintenance_until INTEGER NULL,maintenance_note TEXT NOT NULL DEFAULT '',last_checked_at INTEGER NULL,created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS monitor_checks(id INTEGER PRIMARY KEY AUTOINCREMENT,monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,ok INTEGER NOT NULL,status_code INTEGER NOT NULL DEFAULT 0,latency_ms INTEGER NOT NULL DEFAULT 0,message TEXT NOT NULL DEFAULT '',checked_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_monitor_checks_mon_time ON monitor_checks(monitor_id,checked_at DESC)`, + `CREATE TABLE IF NOT EXISTS audit_log(id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,actor TEXT NOT NULL DEFAULT '',action TEXT NOT NULL,resource TEXT NOT NULL DEFAULT '',detail_json TEXT NOT NULL DEFAULT '{}',ip TEXT NOT NULL DEFAULT '',user_agent TEXT NOT NULL DEFAULT '',status INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action,created_at DESC)`, + `CREATE TABLE IF NOT EXISTS notification_channels(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL UNIQUE,type TEXT NOT NULL,config_json TEXT NOT NULL DEFAULT '{}',enabled INTEGER NOT NULL DEFAULT 1,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_enabled ON notification_channels(enabled,type)`, + `CREATE TABLE IF NOT EXISTS status_pages(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL,slug TEXT NOT NULL UNIQUE,description TEXT NOT NULL DEFAULT '',enabled INTEGER NOT NULL DEFAULT 1,created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS status_page_services(page_id INTEGER NOT NULL REFERENCES status_pages(id) ON DELETE CASCADE,service_id INTEGER NOT NULL REFERENCES monitor_services(id) ON DELETE CASCADE,sort_order INTEGER NOT NULL DEFAULT 0,PRIMARY KEY(page_id,service_id))`, + `CREATE TABLE IF NOT EXISTS git_sources(id INTEGER PRIMARY KEY AUTOINCREMENT,stack_name TEXT NOT NULL UNIQUE,repo_url TEXT NOT NULL,branch TEXT NOT NULL DEFAULT 'main',workdir TEXT NOT NULL DEFAULT '.',compose_file TEXT NOT NULL DEFAULT 'compose.yaml',auto_deploy INTEGER NOT NULL DEFAULT 0,webhook_secret_enc BLOB NOT NULL,last_commit TEXT NOT NULL DEFAULT '',last_sync_at INTEGER NULL,last_error TEXT NOT NULL DEFAULT '',created_at INTEGER NOT NULL,updated_at INTEGER NOT NULL)`} + for i, s := range stmts { + if _, err := db.ExecContext(ctx, s); err != nil { + return fmt.Errorf("migration %d: %w", i+1, err) + } + } + // Additive migrations keep existing installations compatible. + cols := map[string]string{ + "method": "TEXT NOT NULL DEFAULT 'GET'", + "headers_json": "TEXT NOT NULL DEFAULT '{}'", + "body": "TEXT NOT NULL DEFAULT ''", + "keyword": "TEXT NOT NULL DEFAULT ''", + "invert_keyword": "INTEGER NOT NULL DEFAULT 0", + "ignore_tls": "INTEGER NOT NULL DEFAULT 0", + "maintenance_until": "INTEGER NULL", + "maintenance_note": "TEXT NOT NULL DEFAULT ''", + "service_id": "INTEGER NULL REFERENCES monitor_services(id) ON DELETE SET NULL", + "require_healthy": "INTEGER NOT NULL DEFAULT 0", + } + for name, def := range cols { + if err := ensureColumn(ctx, db, "monitors", name, def); err != nil { + return err + } + } + if err := ensureColumn(ctx, db, "git_sources", "node_id", "INTEGER NULL REFERENCES nodes(id) ON DELETE SET NULL"); err != nil { + return err + } + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_monitors_service ON monitors(service_id,name)`, + `CREATE INDEX IF NOT EXISTS idx_monitors_schedule ON monitors(enabled,last_checked_at,interval_seconds)`, + `CREATE INDEX IF NOT EXISTS idx_monitors_status ON monitors(status)`, + `CREATE INDEX IF NOT EXISTS idx_status_page_services_page ON status_page_services(page_id,sort_order)`, + `CREATE INDEX IF NOT EXISTS idx_git_sources_node ON git_sources(node_id)`, + } + for _, stmt := range indexes { + if _, err := db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("create index: %w", err) + } + } + _, _ = db.ExecContext(ctx, `PRAGMA optimize`) + return nil +} + +func ensureColumn(ctx context.Context, db *sql.DB, table, column, definition string) error { + rows, err := db.QueryContext(ctx, "PRAGMA table_info("+table+")") + if err != nil { + return err + } + found := false + for rows.Next() { + var cid int + var name, typ string + var notnull, pk int + var dflt any + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + _ = rows.Close() + return err + } + if name == column { + found = true + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if found { + return nil + } + if _, err := db.ExecContext(ctx, "ALTER TABLE "+table+" ADD COLUMN "+column+" "+definition); err != nil { + return fmt.Errorf("add %s.%s: %w", table, column, err) + } + return nil +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..220db5b --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,57 @@ +package db + +import ( + "context" + "path/filepath" + "testing" +) + +func TestFreshDatabaseHasCurrentMonitorSchemaAndIndexes(t *testing.T) { + db, err := Open(filepath.Join(t.TempDir(), "dockwatch.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ctx := context.Background() + + rows, err := db.QueryContext(ctx, `PRAGMA table_info(monitors)`) + if err != nil { + t.Fatal(err) + } + cols := map[string]bool{} + for rows.Next() { + var cid, notnull, pk int + var name, typ string + var dflt any + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + t.Fatal(err) + } + cols[name] = true + } + _ = rows.Close() + for _, name := range []string{"service_id", "method", "headers_json", "maintenance_until", "require_healthy"} { + if !cols[name] { + t.Fatalf("fresh monitors schema is missing %s", name) + } + } + + idx, err := db.QueryContext(ctx, `PRAGMA index_list(monitors)`) + if err != nil { + t.Fatal(err) + } + indexes := map[string]bool{} + for idx.Next() { + var seq, unique, partial int + var name, origin string + if err := idx.Scan(&seq, &name, &unique, &origin, &partial); err != nil { + t.Fatal(err) + } + indexes[name] = true + } + _ = idx.Close() + for _, name := range []string{"idx_monitors_service", "idx_monitors_schedule", "idx_monitors_status"} { + if !indexes[name] { + t.Fatalf("fresh database is missing index %s", name) + } + } +} diff --git a/internal/gitops/gitops.go b/internal/gitops/gitops.go new file mode 100644 index 0000000..07a97fb --- /dev/null +++ b/internal/gitops/gitops.go @@ -0,0 +1,765 @@ +package gitops + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + + "git.send.nrw/sendnrw/dockwatch/internal/nodes" + "git.send.nrw/sendnrw/dockwatch/internal/stacks" +) + +var stackNameRx = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) + +type Source struct { + ID int64 `json:"id"` + NodeID *int64 `json:"node_id,omitempty"` + StackName string `json:"stack_name"` + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + Workdir string `json:"workdir"` + ComposeFile string `json:"compose_file"` + AutoDeploy bool `json:"auto_deploy"` + LastCommit string `json:"last_commit"` + LastSyncAt *int64 `json:"last_sync_at,omitempty"` + LastError string `json:"last_error"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} +type Input struct { + NodeID *int64 `json:"node_id"` + StackName string `json:"stack_name"` + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + Workdir string `json:"workdir"` + ComposeFile string `json:"compose_file"` + AutoDeploy bool `json:"auto_deploy"` +} +type Service struct { + db *sql.DB + key []byte + stacks *stacks.Service + nodes *nodes.Manager + locks sync.Map +} + +func New(db *sql.DB, key []byte, ss *stacks.Service, nm *nodes.Manager) *Service { + return &Service{db: db, key: key, stacks: ss, nodes: nm} +} +func normalize(in *Input) error { + in.StackName = strings.TrimSpace(in.StackName) + in.RepoURL = strings.TrimSpace(in.RepoURL) + in.Branch = strings.TrimSpace(in.Branch) + in.Workdir = filepath.Clean(strings.TrimSpace(in.Workdir)) + in.ComposeFile = filepath.Clean(strings.TrimSpace(in.ComposeFile)) + if in.StackName == "" || in.RepoURL == "" { + return errors.New("stack_name and repo_url required") + } + if !stackNameRx.MatchString(in.StackName) || strings.ContainsAny(in.RepoURL, "\r\n") || strings.HasPrefix(in.RepoURL, "-") { + return errors.New("invalid stack name or repository URL") + } + if len(in.RepoURL) > 4096 || len(in.Branch) > 255 || strings.ContainsAny(in.Branch, "\r\n") { + return errors.New("git source fields too long") + } + if in.Branch == "" { + in.Branch = "main" + } + if in.Workdir == "." || in.Workdir == "" { + in.Workdir = "." + } + if strings.HasPrefix(in.Workdir, "..") || filepath.IsAbs(in.Workdir) { + return errors.New("invalid workdir") + } + if in.ComposeFile == "." || in.ComposeFile == "" { + in.ComposeFile = "compose.yaml" + } + if strings.HasPrefix(in.ComposeFile, "..") || filepath.IsAbs(in.ComposeFile) { + return errors.New("invalid compose_file") + } + return nil +} +func (s *Service) List(ctx context.Context) ([]Source, error) { + rows, e := s.db.QueryContext(ctx, `SELECT id,node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,last_commit,last_sync_at,last_error,created_at,updated_at FROM git_sources ORDER BY stack_name`) + if e != nil { + return nil, e + } + defer rows.Close() + out := []Source{} + for rows.Next() { + var x Source + var sync, node sql.NullInt64 + if e := rows.Scan(&x.ID, &node, &x.StackName, &x.RepoURL, &x.Branch, &x.Workdir, &x.ComposeFile, &x.AutoDeploy, &x.LastCommit, &sync, &x.LastError, &x.CreatedAt, &x.UpdatedAt); e != nil { + return nil, e + } + if node.Valid { + v := node.Int64 + x.NodeID = &v + } + if sync.Valid { + v := sync.Int64 + x.LastSyncAt = &v + } + out = append(out, x) + } + return out, rows.Err() +} +func (s *Service) Get(ctx context.Context, id int64) (Source, error) { + var x Source + var sync, node sql.NullInt64 + e := s.db.QueryRowContext(ctx, `SELECT id,node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,last_commit,last_sync_at,last_error,created_at,updated_at FROM git_sources WHERE id=?`, id).Scan(&x.ID, &node, &x.StackName, &x.RepoURL, &x.Branch, &x.Workdir, &x.ComposeFile, &x.AutoDeploy, &x.LastCommit, &sync, &x.LastError, &x.CreatedAt, &x.UpdatedAt) + if node.Valid { + v := node.Int64 + x.NodeID = &v + } + if sync.Valid { + v := sync.Int64 + x.LastSyncAt = &v + } + return x, e +} +func (s *Service) Create(ctx context.Context, in Input) (Source, string, error) { + if e := normalize(&in); e != nil { + return Source{}, "", e + } + secret := make([]byte, 32) + if _, e := rand.Read(secret); e != nil { + return Source{}, "", e + } + sec := hex.EncodeToString(secret) + enc, e := s.encrypt([]byte(sec)) + if e != nil { + return Source{}, "", e + } + now := time.Now().Unix() + r, e := s.db.ExecContext(ctx, `INSERT INTO git_sources(node_id,stack_name,repo_url,branch,workdir,compose_file,auto_deploy,webhook_secret_enc,last_commit,last_error,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'','',?,?)`, in.NodeID, in.StackName, in.RepoURL, in.Branch, in.Workdir, in.ComposeFile, in.AutoDeploy, enc, now, now) + if e != nil { + return Source{}, "", e + } + id, _ := r.LastInsertId() + x, e := s.Get(ctx, id) + return x, sec, e +} +func (s *Service) Update(ctx context.Context, id int64, in Input) (Source, error) { + if e := normalize(&in); e != nil { + return Source{}, e + } + now := time.Now().Unix() + r, e := s.db.ExecContext(ctx, `UPDATE git_sources SET node_id=?,stack_name=?,repo_url=?,branch=?,workdir=?,compose_file=?,auto_deploy=?,updated_at=? WHERE id=?`, in.NodeID, in.StackName, in.RepoURL, in.Branch, in.Workdir, in.ComposeFile, in.AutoDeploy, now, id) + if e != nil { + return Source{}, e + } + n, _ := r.RowsAffected() + if n == 0 { + return Source{}, sql.ErrNoRows + } + return s.Get(ctx, id) +} +func (s *Service) Delete(ctx context.Context, id int64) error { + _, e := s.db.ExecContext(ctx, `DELETE FROM git_sources WHERE id=?`, id) + return e +} +func (s *Service) RotateSecret(ctx context.Context, id int64) (string, error) { + secret := make([]byte, 32) + if _, e := rand.Read(secret); e != nil { + return "", e + } + sec := hex.EncodeToString(secret) + enc, e := s.encrypt([]byte(sec)) + if e != nil { + return "", e + } + _, e = s.db.ExecContext(ctx, `UPDATE git_sources SET webhook_secret_enc=?,updated_at=? WHERE id=?`, enc, time.Now().Unix(), id) + return sec, e +} +func (s *Service) VerifyWebhook(ctx context.Context, id int64, body []byte, signature, token string) error { + var enc []byte + if e := s.db.QueryRowContext(ctx, `SELECT webhook_secret_enc FROM git_sources WHERE id=?`, id).Scan(&enc); e != nil { + return e + } + plain, e := s.decrypt(enc) + if e != nil { + return e + } + secret := string(plain) + if token != "" && hmac.Equal([]byte(token), []byte(secret)) { + return nil + } + signature = strings.TrimPrefix(signature, "sha256=") + if signature != "" { + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + if hmac.Equal([]byte(strings.ToLower(signature)), []byte(want)) { + return nil + } + } + return errors.New("invalid webhook signature") +} +func (s *Service) lockFor(id int64) *sync.Mutex { + v, _ := s.locks.LoadOrStore(id, &sync.Mutex{}) + return v.(*sync.Mutex) +} + +func (s *Service) Sync(ctx context.Context, id int64) (Source, error) { + mu := s.lockFor(id) + mu.Lock() + defer mu.Unlock() + x, e := s.Get(ctx, id) + if e != nil { + return x, e + } + in := Input{NodeID: x.NodeID, StackName: x.StackName, RepoURL: x.RepoURL, Branch: x.Branch, Workdir: x.Workdir, ComposeFile: x.ComposeFile, AutoDeploy: x.AutoDeploy} + var commit string + if x.NodeID != nil { + if s.nodes == nil { + return s.syncFailed(ctx, x, errors.New("node manager unavailable")) + } + b, _, err := s.nodes.Do(ctx, *x.NodeID, "POST", "/agent/v1/git/sync", in) + if err != nil { + return s.syncFailed(ctx, x, err) + } + var resp struct { + Commit string `json:"commit"` + } + if err = json.Unmarshal(b, &resp); err != nil { + return s.syncFailed(ctx, x, err) + } + commit = resp.Commit + } else { + commit, e = s.SyncTransient(ctx, in) + if e != nil { + return s.syncFailed(ctx, x, e) + } + } + now := time.Now().Unix() + _, _ = s.db.ExecContext(ctx, `UPDATE git_sources SET last_commit=?,last_sync_at=?,last_error='',updated_at=? WHERE id=?`, commit, now, now, id) + return s.Get(ctx, id) +} + +// SyncTransient performs a Git-backed stack synchronization on the current +// Docker environment. Agents expose this operation to the master without +// persisting Git source metadata locally. +func (s *Service) SyncTransient(ctx context.Context, in Input) (string, error) { + if e := normalize(&in); e != nil { + return "", e + } + tmp, e := os.MkdirTemp("", "dockwatch-git-*") + if e != nil { + return "", e + } + defer os.RemoveAll(tmp) + cloneDir := filepath.Join(tmp, "repo") + cctx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + cmd := exec.CommandContext(cctx, "git", "clone", "--depth", "1", "--branch", in.Branch, "--single-branch", in.RepoURL, cloneDir) + out, e := cmd.CombinedOutput() + if e != nil { + return "", fmt.Errorf("git clone: %w: %s", e, strings.TrimSpace(string(out))) + } + commitRaw, e := exec.CommandContext(cctx, "git", "-C", cloneDir, "rev-parse", "HEAD").Output() + if e != nil { + return "", e + } + src := filepath.Join(cloneDir, in.Workdir) + if fi, e := os.Stat(src); e != nil || !fi.IsDir() { + return "", errors.New("git workdir not found") + } + if _, e := os.Stat(filepath.Join(src, in.ComposeFile)); e != nil { + return "", fmt.Errorf("compose file not found: %s", in.ComposeFile) + } + dst := filepath.Join(s.stacks.Root(), in.StackName) + stage := filepath.Join(tmp, "stage") + if e := copyDir(src, stage); e != nil { + return "", e + } + if filepath.Clean(in.ComposeFile) != "compose.yaml" { + b, e := os.ReadFile(filepath.Join(stage, in.ComposeFile)) + if e != nil { + return "", e + } + if e = os.WriteFile(filepath.Join(stage, "compose.yaml"), b, 0640); e != nil { + return "", e + } + } + if e := s.stacks.ValidateProject(ctx, in.StackName, stage, "compose.yaml"); e != nil { + return "", e + } + if e := syncManagedTree(stage, dst); e != nil { + return "", e + } + if in.AutoDeploy { + if _, e = s.stacks.Action(ctx, in.StackName, "up"); e != nil { + return "", e + } + } + return strings.TrimSpace(string(commitRaw)), nil +} + +func (s *Service) syncFailed(ctx context.Context, x Source, e error) (Source, error) { + _, _ = s.db.ExecContext(ctx, `UPDATE git_sources SET last_error=?,updated_at=? WHERE id=?`, e.Error(), time.Now().Unix(), x.ID) + x.LastError = e.Error() + return x, e +} + +const gitManifestPath = ".dockwatch/git-manifest.json" + +type gitManifest struct { + Files []string `json:"files"` +} + +// syncManagedTree applies a Git checkout without treating the stack directory as +// disposable storage. Only files previously managed by Git and files present in +// the new checkout are changed. Unrelated files (for example bind-mount data) +// survive a sync. The touched files are snapshotted so a failed apply can be +// rolled back without copying or deleting the whole stack directory. +func syncManagedTree(stage, dst string) error { + files, err := collectManagedFiles(stage) + if err != nil { + return err + } + if err := ensureSafeRoot(dst); err != nil { + return err + } + old, err := readGitManifest(dst) + if err != nil { + return err + } + backup, err := os.MkdirTemp("", "dockwatch-git-rollback-*") + if err != nil { + return err + } + defer os.RemoveAll(backup) + + affected := map[string]struct{}{} + for _, rel := range old { + affected[rel] = struct{}{} + } + for _, rel := range files { + affected[rel] = struct{}{} + } + manifestAbs := filepath.Join(dst, filepath.FromSlash(gitManifestPath)) + manifestBackup := filepath.Join(backup, "manifest.json") + manifestExisted := false + if info, err := os.Lstat(manifestAbs); err == nil { + if !info.Mode().IsRegular() { + return errors.New("Git manifest path is not a regular file") + } + if err := copyRegularFile(manifestAbs, manifestBackup, info.Mode().Perm()); err != nil { + return err + } + manifestExisted = true + } else if !os.IsNotExist(err) { + return err + } + + backedUp := map[string]bool{} + for rel := range affected { + target, err := safeManagedPath(dst, rel) + if err != nil { + return err + } + if err := ensureSafeParent(dst, filepath.Dir(target)); err != nil { + return err + } + info, err := os.Lstat(target) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to replace symlink in Git-managed path %q", rel) + } + if info.Mode().IsRegular() { + bp := filepath.Join(backup, filepath.FromSlash(rel)) + if err := copyRegularFile(target, bp, info.Mode().Perm()); err != nil { + return err + } + backedUp[rel] = true + } + } + + rollback := func() { + for rel := range affected { + target, err := safeManagedPath(dst, rel) + if err != nil { + continue + } + if info, err := os.Lstat(target); err == nil && info.Mode().IsRegular() { + _ = os.Remove(target) + } + if backedUp[rel] { + bp := filepath.Join(backup, filepath.FromSlash(rel)) + if info, err := os.Stat(bp); err == nil { + _ = copyRegularFile(bp, target, info.Mode().Perm()) + } + } + } + _ = os.Remove(manifestAbs) + if manifestExisted { + if info, err := os.Stat(manifestBackup); err == nil { + _ = copyRegularFile(manifestBackup, manifestAbs, info.Mode().Perm()) + } + } + } + + newSet := make(map[string]struct{}, len(files)) + for _, rel := range files { + newSet[rel] = struct{}{} + } + // Remove files that disappeared from Git, but never recursively remove a + // directory. This deliberately leaves unrelated data untouched. + for _, rel := range old { + if _, ok := newSet[rel]; ok { + continue + } + target, err := safeManagedPath(dst, rel) + if err != nil { + rollback() + return err + } + if info, err := os.Lstat(target); err == nil { + if info.Mode()&os.ModeSymlink != 0 || (!info.Mode().IsRegular() && !info.IsDir()) { + rollback() + return fmt.Errorf("refusing to remove non-regular Git-managed path %q", rel) + } + if info.Mode().IsRegular() { + if err := os.Remove(target); err != nil { + rollback() + return err + } + } + } else if !os.IsNotExist(err) { + rollback() + return err + } + } + + for _, rel := range files { + source, err := safeManagedPath(stage, rel) + if err != nil { + rollback() + return err + } + target, err := safeManagedPath(dst, rel) + if err != nil { + rollback() + return err + } + if err := ensureSafeParent(dst, filepath.Dir(target)); err != nil { + rollback() + return err + } + if info, err := os.Lstat(target); err == nil && info.IsDir() { + // A file replacing a directory is safe only when that directory is + // empty. os.Remove intentionally refuses non-empty directories. + if err := os.Remove(target); err != nil { + rollback() + return fmt.Errorf("Git file %q conflicts with existing directory containing unmanaged data: %w", rel, err) + } + } else if err == nil && info.Mode()&os.ModeSymlink != 0 { + rollback() + return fmt.Errorf("refusing to replace symlink in Git-managed path %q", rel) + } else if err != nil && !os.IsNotExist(err) { + rollback() + return err + } + info, err := os.Stat(source) + if err != nil { + rollback() + return err + } + if err := copyRegularFile(source, target, info.Mode().Perm()); err != nil { + rollback() + return err + } + } + + if err := writeGitManifest(dst, files); err != nil { + rollback() + return err + } + return nil +} + +func collectManagedFiles(root string) ([]string, error) { + out := []string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == gitManifestPath { + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("Git stack contains unsupported symlink %q", rel) + } + if info.IsDir() { + return nil + } + if info.Mode().IsRegular() { + out = append(out, rel) + } + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(out) + return out, nil +} + +func readGitManifest(dst string) ([]string, error) { + path := filepath.Join(dst, filepath.FromSlash(gitManifestPath)) + if err := ensureSafeParent(dst, filepath.Dir(path)); err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return []string{}, nil + } + if err != nil { + return nil, err + } + var m gitManifest + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("invalid Git-managed file manifest: %w", err) + } + out := make([]string, 0, len(m.Files)) + seen := map[string]bool{} + for _, rel := range m.Files { + rel = filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))) + if rel == "." || rel == gitManifestPath || strings.HasPrefix(rel, "../") || filepath.IsAbs(filepath.FromSlash(rel)) || seen[rel] { + continue + } + seen[rel] = true + out = append(out, rel) + } + return out, nil +} + +func writeGitManifest(dst string, files []string) error { + path := filepath.Join(dst, filepath.FromSlash(gitManifestPath)) + if err := ensureSafeParent(dst, filepath.Dir(path)); err != nil { + return err + } + b, err := json.MarshalIndent(gitManifest{Files: files}, "", " ") + if err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".dockwatch-git-manifest-*") + if err != nil { + return err + } + tmp := f.Name() + defer os.Remove(tmp) + if err := f.Chmod(0640); err != nil { + _ = f.Close() + return err + } + if _, err := f.Write(append(b, '\n')); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func safeManagedPath(root, rel string) (string, error) { + rel = filepath.Clean(filepath.FromSlash(rel)) + if rel == "." || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", errors.New("invalid Git-managed path") + } + return filepath.Join(root, rel), nil +} + +func ensureSafeRoot(root string) error { + info, err := os.Lstat(root) + if os.IsNotExist(err) { + return os.MkdirAll(root, 0750) + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("Git stack destination must be a real directory") + } + return nil +} + +func ensureSafeParent(root, parent string) error { + rel, err := filepath.Rel(root, parent) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return errors.New("path escapes stack root") + } + cur := root + if err := ensureSafeRoot(root); err != nil { + return err + } + if rel == "." { + return nil + } + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + cur = filepath.Join(cur, part) + info, err := os.Lstat(cur) + if os.IsNotExist(err) { + if err := os.Mkdir(cur, 0750); err != nil && !os.IsExist(err) { + return err + } + continue + } + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("unsafe parent path %q", cur) + } + } + return nil +} + +func copyRegularFile(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0750); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), ".dockwatch-git-write-*") + if err != nil { + _ = in.Close() + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if err := tmp.Chmod(mode); err != nil { + _ = in.Close() + _ = tmp.Close() + return err + } + _, copyErr := io.Copy(tmp, in) + inErr := in.Close() + if copyErr == nil { + copyErr = tmp.Sync() + } + outErr := tmp.Close() + if copyErr != nil { + return copyErr + } + if inErr != nil { + return inErr + } + if outErr != nil { + return outErr + } + return os.Rename(tmpName, dst) +} + +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, e error) error { + if e != nil { + return e + } + rel, e := filepath.Rel(src, path) + if e != nil { + return e + } + if rel == ".git" || strings.HasPrefix(rel, ".git"+string(os.PathSeparator)) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + if !info.Mode().IsRegular() { + return nil + } + if e := os.MkdirAll(filepath.Dir(target), 0750); e != nil { + return e + } + in, e := os.Open(path) + if e != nil { + return e + } + out, e := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm()) + if e != nil { + _ = in.Close() + return e + } + _, copyErr := io.Copy(out, in) + inErr := in.Close() + outErr := out.Close() + if copyErr != nil { + return copyErr + } + if inErr != nil { + return inErr + } + return outErr + }) +} +func (s *Service) encrypt(p []byte) ([]byte, error) { + b, e := aes.NewCipher(s.key) + if e != nil { + return nil, e + } + g, e := cipher.NewGCM(b) + if e != nil { + return nil, e + } + nonce := make([]byte, g.NonceSize()) + if _, e = rand.Read(nonce); e != nil { + return nil, e + } + return g.Seal(nonce, nonce, p, nil), nil +} +func (s *Service) decrypt(v []byte) ([]byte, error) { + b, e := aes.NewCipher(s.key) + if e != nil { + return nil, e + } + g, e := cipher.NewGCM(b) + if e != nil { + return nil, e + } + if len(v) < g.NonceSize() { + return nil, errors.New("invalid encrypted secret") + } + return g.Open(nil, v[:g.NonceSize()], v[g.NonceSize():], nil) +} diff --git a/internal/gitops/gitops_test.go b/internal/gitops/gitops_test.go new file mode 100644 index 0000000..6fe4019 --- /dev/null +++ b/internal/gitops/gitops_test.go @@ -0,0 +1,83 @@ +package gitops + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeRejectsUnsafeStackAndRepositoryArguments(t *testing.T) { + cases := []Input{ + {StackName: "../escape", RepoURL: "https://example.invalid/repo.git"}, + {StackName: "demo", RepoURL: "--upload-pack=evil"}, + {StackName: "demo", RepoURL: "https://example.invalid/repo.git\n--option"}, + } + for _, in := range cases { + if err := normalize(&in); err == nil { + t.Fatalf("expected unsafe input to be rejected: %#v", in) + } + } +} + +func TestSyncManagedTreePreservesUnmanagedDataAndRemovesStaleGitFiles(t *testing.T) { + dst := t.TempDir() + stage1 := t.TempDir() + if err := os.WriteFile(filepath.Join(stage1, "compose.yaml"), []byte("services: {}\n"), 0640); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(stage1, "config"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stage1, "config", "old.txt"), []byte("old"), 0640); err != nil { + t.Fatal(err) + } + if err := syncManagedTree(stage1, dst); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dst, "data"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dst, "data", "runtime.db"), []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + + stage2 := t.TempDir() + if err := os.WriteFile(filepath.Join(stage2, "compose.yaml"), []byte("services:\n web:\n image: nginx\n"), 0640); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(stage2, "config"), 0750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stage2, "config", "new.txt"), []byte("new"), 0640); err != nil { + t.Fatal(err) + } + if err := syncManagedTree(stage2, dst); err != nil { + t.Fatal(err) + } + + if b, err := os.ReadFile(filepath.Join(dst, "data", "runtime.db")); err != nil || string(b) != "keep" { + t.Fatalf("unmanaged data was not preserved: %q %v", b, err) + } + if _, err := os.Stat(filepath.Join(dst, "config", "old.txt")); !os.IsNotExist(err) { + t.Fatalf("stale Git-managed file still exists: %v", err) + } + if b, err := os.ReadFile(filepath.Join(dst, "config", "new.txt")); err != nil || string(b) != "new" { + t.Fatalf("new Git file missing: %q %v", b, err) + } +} + +func TestSyncManagedTreeRejectsSymlinkDestination(t *testing.T) { + parent := t.TempDir() + outside := t.TempDir() + dst := filepath.Join(parent, "demo") + if err := os.Symlink(outside, dst); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + stage := t.TempDir() + if err := os.WriteFile(filepath.Join(stage, "compose.yaml"), []byte("services: {}\n"), 0640); err != nil { + t.Fatal(err) + } + if err := syncManagedTree(stage, dst); err == nil { + t.Fatal("expected symlink Git destination to be rejected") + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go new file mode 100644 index 0000000..735ff08 --- /dev/null +++ b/internal/httpapi/httpapi.go @@ -0,0 +1,1245 @@ +package httpapi + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "html/template" + "io" + "io/fs" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "git.send.nrw/sendnrw/dockwatch/internal/audit" + "git.send.nrw/sendnrw/dockwatch/internal/auth" + "git.send.nrw/sendnrw/dockwatch/internal/buildinfo" + "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/monitor" + "git.send.nrw/sendnrw/dockwatch/internal/nodes" + "git.send.nrw/sendnrw/dockwatch/internal/notify" + "git.send.nrw/sendnrw/dockwatch/internal/stacks" + web "git.send.nrw/sendnrw/dockwatch/web" + "github.com/gorilla/websocket" +) + +type Server struct { + cfg config.Config + auth *auth.Service + stacks *stacks.Service + nodes *nodes.Manager + monitors *monitor.Service + audit *audit.Service + notify *notify.Service + git *gitops.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} + 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()}) + }) + if c.Mode == config.ModeAgent { + s.agent(mux) + return securityHeaders(mux) + } + mux.HandleFunc("POST /hooks/git/{id}", s.gitWebhook) + mux.HandleFunc("GET /status/{slug}", s.publicStatusPage) + mux.HandleFunc("GET /public/api/status/{slug}", s.publicStatusJSON) + mux.HandleFunc("GET /auth/login", a.Login) + mux.HandleFunc("GET /auth/callback", func(w http.ResponseWriter, r *http.Request) { + if e := a.Callback(w, r); e != nil { + http.Error(w, e.Error(), 401) + return + } + http.Redirect(w, r, "/", 302) + }) + mux.Handle("POST /auth/logout", a.Middleware(mutationOriginGuard(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + a.Logout(w, r) + jsonOut(w, 200, map[string]bool{"ok": true}) + })))) + api := http.NewServeMux() + api.HandleFunc("GET /api/me", func(w http.ResponseWriter, r *http.Request) { u, _ := auth.UserFrom(r.Context()); jsonOut(w, 200, u) }) + api.HandleFunc("GET /api/system", func(w http.ResponseWriter, r *http.Request) { + out := map[string]any{"mode": c.Mode, "build": buildinfo.Current()} + if u, ok := auth.UserFrom(r.Context()); ok && u.Role == "admin" { + out["secret_fingerprint"] = c.SecretFingerprint() + } + jsonOut(w, 200, out) + }) + api.HandleFunc("GET /api/monitors", s.listMonitors) + api.HandleFunc("GET /api/monitors/{id}", s.getMonitor) + api.Handle("POST /api/monitors", auth.RequireRole("operator", http.HandlerFunc(s.createMonitor))) + api.Handle("PUT /api/monitors/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateMonitor))) + api.Handle("POST /api/monitors/{id}/pause", auth.RequireRole("operator", http.HandlerFunc(s.pauseMonitor))) + api.Handle("POST /api/monitors/{id}/resume", auth.RequireRole("operator", http.HandlerFunc(s.resumeMonitor))) + api.Handle("POST /api/monitors/{id}/maintenance", auth.RequireRole("operator", http.HandlerFunc(s.maintenanceMonitor))) + api.Handle("DELETE /api/monitors/{id}/maintenance", auth.RequireRole("operator", http.HandlerFunc(s.clearMaintenance))) + api.Handle("DELETE /api/monitors/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteMonitor))) + api.Handle("POST /api/monitors/{id}/check", auth.RequireRole("operator", http.HandlerFunc(s.checkMonitorNow))) + api.HandleFunc("GET /api/monitors/{id}/checks", s.checks) + api.HandleFunc("GET /api/services", s.listServices) + api.Handle("POST /api/services", auth.RequireRole("operator", http.HandlerFunc(s.createService))) + api.Handle("PUT /api/services/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateService))) + api.Handle("DELETE /api/services/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteService))) + api.HandleFunc("GET /api/status-pages", s.listStatusPages) + api.Handle("POST /api/status-pages", auth.RequireRole("admin", http.HandlerFunc(s.createStatusPage))) + api.Handle("PUT /api/status-pages/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateStatusPage))) + api.Handle("DELETE /api/status-pages/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteStatusPage))) + api.HandleFunc("GET /api/docker/{kind}", s.dockerInventory) + api.Handle("POST /api/docker/{kind}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.dockerAction))) + api.Handle("GET /api/docker/{kind}/{id}/inspect", auth.RequireRole("operator", http.HandlerFunc(s.dockerInspect))) + api.HandleFunc("GET /api/stacks", s.listStacks) + api.Handle("GET /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.getStack))) + api.HandleFunc("POST /api/compose/parse", s.composeParse) + api.Handle("POST /api/compose/patch", auth.RequireRole("operator", http.HandlerFunc(s.composePatch))) + api.Handle("PUT /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.saveStack))) + api.Handle("POST /api/stacks/{name}/actions/{action}", auth.RequireRole("operator", http.HandlerFunc(s.stackAction))) + api.Handle("POST /api/stacks/{name}/exec", auth.RequireRole("operator", http.HandlerFunc(s.execStack))) + api.Handle("GET /api/stacks/{name}/logs", auth.RequireRole("operator", http.HandlerFunc(s.logs))) + api.Handle("DELETE /api/stacks/{name}", auth.RequireRole("operator", http.HandlerFunc(s.deleteStack))) + api.HandleFunc("GET /api/stacks/{name}/graph", s.stackGraph) + api.HandleFunc("GET /api/stacks/{name}/image-updates", s.stackImageUpdates) + api.Handle("GET /api/stacks/{name}/terminal", auth.RequireRole("operator", http.HandlerFunc(s.stackTerminal))) + api.Handle("GET /api/activity", auth.RequireRole("admin", http.HandlerFunc(s.activity))) + api.Handle("GET /api/notifications", auth.RequireRole("admin", http.HandlerFunc(s.listNotifications))) + api.Handle("POST /api/notifications", auth.RequireRole("admin", http.HandlerFunc(s.createNotification))) + api.Handle("PUT /api/notifications/{id}", auth.RequireRole("admin", http.HandlerFunc(s.updateNotification))) + api.Handle("DELETE /api/notifications/{id}", auth.RequireRole("admin", http.HandlerFunc(s.deleteNotification))) + api.Handle("POST /api/notifications/{id}/test", auth.RequireRole("admin", http.HandlerFunc(s.testNotification))) + api.HandleFunc("GET /api/git-sources", s.listGitSources) + api.Handle("POST /api/git-sources", auth.RequireRole("operator", http.HandlerFunc(s.createGitSource))) + api.Handle("PUT /api/git-sources/{id}", auth.RequireRole("operator", http.HandlerFunc(s.updateGitSource))) + api.Handle("DELETE /api/git-sources/{id}", auth.RequireRole("operator", http.HandlerFunc(s.deleteGitSource))) + api.Handle("POST /api/git-sources/{id}/sync", auth.RequireRole("operator", http.HandlerFunc(s.syncGitSource))) + api.Handle("POST /api/git-sources/{id}/rotate-secret", auth.RequireRole("admin", http.HandlerFunc(s.rotateGitSecret))) + api.HandleFunc("GET /api/nodes", s.listNodes) + api.Handle("POST /api/nodes", auth.RequireRole("admin", http.HandlerFunc(s.createNode))) + 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) + mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api)))) + assets, _ := fs.Sub(web.FS, ".") + f := http.FileServer(http.FS(assets)) + mux.Handle("GET /app.js", f) + mux.Handle("GET /styles.css", f) + mux.Handle("GET /{$}", f) + return securityHeaders(mux) +} +func (s *Server) agent(m *http.ServeMux) { + a := http.NewServeMux() + a.HandleFunc("GET /agent/v1/health", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]any{"ok": true, "mode": config.ModeAgent, "build": buildinfo.Current()}) + }) + a.HandleFunc("GET /agent/v1/docker/{kind}", s.localDockerInventory) + a.HandleFunc("POST /agent/v1/docker/{kind}/actions/{action}", s.localDockerAction) + a.HandleFunc("GET /agent/v1/docker/{kind}/{id}/inspect", s.localDockerInspect) + 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) + a.HandleFunc("POST /agent/v1/stacks/{name}/actions/{action}", s.localAction) + a.HandleFunc("GET /agent/v1/stacks/{name}/logs", s.localLogs) + a.HandleFunc("POST /agent/v1/stacks/{name}/exec", s.localExec) + a.HandleFunc("POST /agent/v1/git/sync", s.localGitSync) + a.HandleFunc("DELETE /agent/v1/stacks/{name}", s.localDelete) + a.HandleFunc("GET /agent/v1/stacks/{name}/graph", s.localGraph) + a.HandleFunc("GET /agent/v1/stacks/{name}/image-updates", s.localImageUpdates) + a.HandleFunc("GET /agent/v1/stacks/{name}/terminal", s.localTerminal) + a.HandleFunc("POST /agent/v1/probe", func(w http.ResponseWriter, r *http.Request) { + var in monitor.Input + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, monitor.Probe(r.Context(), in)) + }) + m.Handle("/agent/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if len(got) != len(s.cfg.AgentToken) || subtle.ConstantTimeCompare([]byte(got), []byte(s.cfg.AgentToken)) != 1 { + http.Error(w, "unauthorized", 401) + return + } + a.ServeHTTP(w, r) + })) +} +func (s *Server) composeParse(w http.ResponseWriter, r *http.Request) { + var in struct { + Compose string `json:"compose"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := composeedit.Parse(in.Compose) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) composePatch(w http.ResponseWriter, r *http.Request) { + var in struct { + Compose string `json:"compose"` + Path []string `json:"path"` + Value any `json:"value"` + Delete bool `json:"delete"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + out, e := composeedit.Apply(in.Compose, composeedit.Patch{Path: in.Path, Value: in.Value, Delete: in.Delete}) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]any{"compose": out}) +} + +func canReadSensitiveConfig(r *http.Request) bool { + u, ok := auth.UserFrom(r.Context()) + return ok && (u.Role == "operator" || u.Role == "admin") +} + +func sanitizeMonitorForViewer(m *monitor.Monitor) { + m.HeadersJSON = "{}" + m.Body = "" + m.Keyword = "" +} + +func (s *Server) listMonitors(w http.ResponseWriter, r *http.Request) { + v, e := s.monitors.List(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + if !canReadSensitiveConfig(r) { + for i := range v { + sanitizeMonitorForViewer(&v[i]) + } + } + jsonOut(w, 200, v) +} +func (s *Server) getMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.Get(r.Context(), id) + if e != nil { + http.Error(w, e.Error(), 404) + return + } + if !canReadSensitiveConfig(r) { + sanitizeMonitorForViewer(&v) + } + jsonOut(w, 200, v) +} +func (s *Server) updateMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + var in monitor.Input + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.Update(r.Context(), id, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) pauseMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.SetPaused(r.Context(), id, true); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) resumeMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.SetPaused(r.Context(), id, false); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) maintenanceMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + var in monitor.MaintenanceInput + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.SetMaintenance(r.Context(), id, in); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) clearMaintenance(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.ClearMaintenance(r.Context(), id); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) createMonitor(w http.ResponseWriter, r *http.Request) { + var in monitor.Input + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + u, _ := auth.UserFrom(r.Context()) + v, e := s.monitors.Create(r.Context(), in, u.ID) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, v) +} +func (s *Server) deleteMonitor(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.Delete(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) checks(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + lim, _ := strconv.Atoi(r.URL.Query().Get("limit")) + v, e := s.monitors.Checks(r.Context(), id, lim) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) checkMonitorNow(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + c, e := s.monitors.CheckNow(r.Context(), id) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, c) +} + +func (s *Server) listServices(w http.ResponseWriter, r *http.Request) { + v, e := s.monitors.ListGroups(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + if !canReadSensitiveConfig(r) { + for gi := range v { + for mi := range v[gi].Monitors { + sanitizeMonitorForViewer(&v[gi].Monitors[mi]) + } + } + } + jsonOut(w, 200, v) +} +func (s *Server) createService(w http.ResponseWriter, r *http.Request) { + var in monitor.ProbeGroupInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.CreateGroup(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, v) +} +func (s *Server) updateService(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + var in monitor.ProbeGroupInput + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.UpdateGroup(r.Context(), id, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) deleteService(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.DeleteGroup(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) listStatusPages(w http.ResponseWriter, r *http.Request) { + v, e := s.monitors.ListStatusPages(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} +func (s *Server) createStatusPage(w http.ResponseWriter, r *http.Request) { + var in monitor.StatusPageInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.CreateStatusPage(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, v) +} +func (s *Server) updateStatusPage(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + var in monitor.StatusPageInput + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.monitors.UpdateStatusPage(r.Context(), id, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) deleteStatusPage(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.monitors.DeleteStatusPage(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} + +type publicProbeJSON struct { + Name string `json:"name"` + Status string `json:"status"` + Uptime24h float64 `json:"uptime_24h"` + LastLatencyMS int64 `json:"last_latency_ms,omitempty"` + LastCheckedAt *int64 `json:"last_checked_at,omitempty"` +} + +type publicServiceJSON struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Probes []publicProbeJSON `json:"probes"` +} + +type publicStatusJSONResponse struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + Services []publicServiceJSON `json:"services"` +} + +func (s *Server) publicStatusJSON(w http.ResponseWriter, r *http.Request) { + v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug")) + if e != nil { + http.Error(w, "status page not found", 404) + return + } + out := publicStatusJSONResponse{Name: v.Name, Slug: v.Slug, Description: v.Description, Status: v.Status, Services: []publicServiceJSON{}} + for _, service := range v.Services { + ps := publicServiceJSON{Name: service.Name, Description: service.Description, Status: service.Status, Probes: []publicProbeJSON{}} + for _, probe := range service.Monitors { + ps.Probes = append(ps.Probes, publicProbeJSON{Name: probe.Name, Status: probe.Status, Uptime24h: probe.Uptime24h, LastLatencyMS: probe.LastLatencyMS, LastCheckedAt: probe.LastCheckedAt}) + } + out.Services = append(out.Services, ps) + } + jsonOut(w, 200, out) +} + +var publicStatusTemplate = template.Must(template.New("status").Funcs(template.FuncMap{"upper": strings.ToUpper}).Parse(`{{.Name}} · Status
Dockwatch Public Status

{{.Name}}

{{.Description}}
{{if eq .Status "up"}}All published services operational{{else if eq .Status "down"}}Service disruption detected{{else if eq .Status "maintenance"}}Maintenance in progress{{else}}Status being evaluated{{end}}
This page refreshes automatically every 30 seconds.
{{upper .Status}}
{{range .Services}}

{{.Name}}

{{.Description}}
{{upper .Status}}
{{range .Monitors}}
{{.Name}}{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}
{{end}}
{{else}}
No public services configured.
{{end}}
`)) + +func (s *Server) publicStatusPage(w http.ResponseWriter, r *http.Request) { + v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug")) + if e != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = publicStatusTemplate.Execute(w, v) +} + +func nodeID(r *http.Request) int64 { + id, _ := strconv.ParseInt(r.URL.Query().Get("node_id"), 10, 64) + return id +} +func (s *Server) relay(w http.ResponseWriter, r *http.Request, id int64, method, path string, body any) { + b, status, e := s.nodes.Do(r.Context(), id, method, path, body) + 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 { + s.relay(w, r, id, "GET", "/agent/v1/docker/"+kind, nil) + return + } + s.localDockerInventory(w, r) +} +func (s *Server) localDockerInventory(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.DockerInventory(r.Context(), r.PathValue("kind")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) dockerAction(w http.ResponseWriter, r *http.Request) { + var in stacks.DockerActionInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + kind, action := r.PathValue("kind"), r.PathValue("action") + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "POST", "/agent/v1/docker/"+kind+"/actions/"+action, in) + return + } + out, e := s.stacks.DockerAction(r.Context(), kind, action, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "output": out}) +} +func (s *Server) localDockerAction(w http.ResponseWriter, r *http.Request) { + var in stacks.DockerActionInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + out, e := s.stacks.DockerAction(r.Context(), r.PathValue("kind"), r.PathValue("action"), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "output": out}) +} +func (s *Server) dockerInspect(w http.ResponseWriter, r *http.Request) { + kind := r.PathValue("kind") + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "GET", "/agent/v1/docker/"+url.PathEscape(kind)+"/"+url.PathEscape(r.PathValue("id"))+"/inspect", nil) + return + } + s.localDockerInspect(w, r) +} +func (s *Server) localDockerInspect(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.DockerInspect(r.Context(), r.PathValue("kind"), r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "GET", "/agent/v1/stacks", nil) + return + } + s.localList(w, r) +} +func (s *Server) getStack(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "GET", "/agent/v1/stacks/"+r.PathValue("name"), nil) + return + } + s.localGet(w, r) +} +func (s *Server) saveStack(w http.ResponseWriter, r *http.Request) { + var b stacks.SaveInput + if e := read(r, &b); e != nil { + http.Error(w, e.Error(), 400) + return + } + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "PUT", "/agent/v1/stacks/"+r.PathValue("name"), b) + return + } + if e := s.stacks.Save(r.Context(), r.PathValue("name"), b); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { + p := "/agent/v1/stacks/" + r.PathValue("name") + "/actions/" + r.PathValue("action") + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "POST", p, nil) + return + } + s.localAction(w, r) +} +func (s *Server) logs(w http.ResponseWriter, r *http.Request) { + path := "/agent/v1/stacks/" + r.PathValue("name") + "/logs" + q := r.URL.RawQuery + if q != "" { + path += "?" + q + } + if id := nodeID(r); id > 0 { + if r.URL.Query().Get("live") == "true" { + if e := s.nodes.Stream(r.Context(), id, "GET", path, nil, w); e != nil { + http.Error(w, e.Error(), 502) + } + return + } + s.relay(w, r, id, "GET", path, nil) + return + } + s.localLogs(w, r) +} +func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + q := url.Values{} + q.Set("down", r.URL.Query().Get("down")) + q.Set("purge", r.URL.Query().Get("purge")) + s.relay(w, r, id, "DELETE", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"?"+q.Encode(), nil) + return + } + s.localDelete(w, r) +} + +func (s *Server) execStack(w http.ResponseWriter, r *http.Request) { + var in stacks.ExecInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + p := "/agent/v1/stacks/" + r.PathValue("name") + "/exec" + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "POST", p, in) + return + } + v, e := s.stacks.Exec(r.Context(), r.PathValue("name"), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"output": v}) +} +func (s *Server) localList(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.List(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} +func (s *Server) localGet(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.Get(r.Context(), r.PathValue("name")) + if e != nil { + http.Error(w, e.Error(), 404) + return + } + jsonOut(w, 200, v) +} +func (s *Server) localSave(w http.ResponseWriter, r *http.Request) { + var b stacks.SaveInput + if e := read(r, &b); e != nil { + http.Error(w, e.Error(), 400) + return + } + if e := s.stacks.Save(r.Context(), r.PathValue("name"), b); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} +func (s *Server) localAction(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.Action(r.Context(), r.PathValue("name"), r.PathValue("action")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"output": v}) +} +func (s *Server) localLogs(w http.ResponseWriter, r *http.Request) { + tail, _ := strconv.Atoi(r.URL.Query().Get("tail")) + if r.URL.Query().Get("live") == "true" { + if e := s.stacks.StreamLogs(r.Context(), r.PathValue("name"), tail, w); e != nil { + http.Error(w, e.Error(), 400) + } + return + } + v, e := s.stacks.Logs(r.Context(), r.PathValue("name"), tail) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"output": v}) +} + +func (s *Server) localExec(w http.ResponseWriter, r *http.Request) { + var in stacks.ExecInput + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.stacks.Exec(r.Context(), r.PathValue("name"), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"output": v}) +} +func (s *Server) localDelete(w http.ResponseWriter, r *http.Request) { + if e := s.stacks.Delete(r.Context(), r.PathValue("name"), r.URL.Query().Get("down") == "true", r.URL.Query().Get("purge") == "true"); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) listNodes(w http.ResponseWriter, r *http.Request) { + v, e := s.nodes.List(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} +func (s *Server) createNode(w http.ResponseWriter, r *http.Request) { + var in struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Token string `json:"token"` + } + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.nodes.Create(r.Context(), in.Name, in.BaseURL, in.Token) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, v) +} +func (s *Server) updateNode(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + var in struct { + Name string `json:"name"` + BaseURL string `json:"base_url"` + Token string `json:"token"` + Enabled *bool `json:"enabled"` + } + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.nodes.Update(r.Context(), id, in.Name, in.BaseURL, in.Token, in.Enabled) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) deleteNode(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + if e = s.nodes.Delete(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) nodeHealth(w http.ResponseWriter, r *http.Request) { + id, e := monitor.ParseID(r.PathValue("id")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + s.relay(w, r, id, "GET", "/agent/v1/health", nil) +} +func (s *Server) localGitSync(w http.ResponseWriter, r *http.Request) { + var in gitops.Input + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + commit, e := s.git.SyncTransient(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"commit": commit}) +} + +func read(r *http.Request, v any) error { + defer r.Body.Close() + d := json.NewDecoder(io.LimitReader(r.Body, 16<<20)) + d.DisallowUnknownFields() + if err := d.Decode(v); err != nil { + return err + } + var extra any + if err := d.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("request body must contain exactly one JSON value") + } + return nil +} +func jsonOut(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + next.ServeHTTP(w, r) + }) +} + +func mutationOriginGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + if strings.EqualFold(r.Header.Get("Sec-Fetch-Site"), "cross-site") { + http.Error(w, "cross-site request rejected", http.StatusForbidden) + return + } + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + u, err := url.Parse(origin) + if err != nil || !strings.EqualFold(u.Host, r.Host) { + http.Error(w, "origin rejected", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (w *statusRecorder) WriteHeader(code int) { w.status = code; w.ResponseWriter.WriteHeader(code) } +func (w *statusRecorder) Write(b []byte) (int, error) { + if w.status == 0 { + w.status = 200 + } + return w.ResponseWriter.Write(b) +} +func (s *Server) auditMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet || r.Method == http.MethodHead || strings.HasSuffix(r.URL.Path, "/terminal") { + next.ServeHTTP(w, r) + return + } + rec := &statusRecorder{ResponseWriter: w} + next.ServeHTTP(rec, r) + u, _ := auth.UserFrom(r.Context()) + uid := u.ID + actor := u.Name + if actor == "" { + actor = u.Email + } + if actor == "" { + actor = "user" + } + ip := r.RemoteAddr + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + ip = host + } + status := rec.status + if status == 0 { + status = 200 + } + _ = s.audit.Log(contextBg(), audit.Entry{UserID: &uid, Actor: actor, Action: r.Method + " " + r.URL.Path, Resource: r.URL.Query().Get("node_id"), Detail: map[string]any{"query": r.URL.RawQuery}, IP: ip, UserAgent: r.UserAgent(), Status: status}) + }) +} +func contextBg() context.Context { return context.Background() } + +func (s *Server) activity(w http.ResponseWriter, r *http.Request) { + lim, _ := strconv.Atoi(r.URL.Query().Get("limit")) + off, _ := strconv.Atoi(r.URL.Query().Get("offset")) + v, e := s.audit.List(r.Context(), lim, off, r.URL.Query().Get("action")) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} + +func parseIntID(r *http.Request) (int64, error) { return strconv.ParseInt(r.PathValue("id"), 10, 64) } +func (s *Server) listNotifications(w http.ResponseWriter, r *http.Request) { + v, e := s.notify.List(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} +func (s *Server) createNotification(w http.ResponseWriter, r *http.Request) { + var in notify.Input + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.notify.Create(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, v) +} +func (s *Server) updateNotification(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + var in notify.Input + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.notify.Update(r.Context(), id, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) deleteNotification(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + if e = s.notify.Delete(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) testNotification(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + if e = s.notify.Test(r.Context(), id); e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} + +func (s *Server) listGitSources(w http.ResponseWriter, r *http.Request) { + v, e := s.git.List(r.Context()) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + jsonOut(w, 200, v) +} +func (s *Server) createGitSource(w http.ResponseWriter, r *http.Request) { + var in gitops.Input + if e := read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, secret, e := s.git.Create(r.Context(), in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 201, map[string]any{"source": v, "webhook_secret": secret, "webhook_url": strings.TrimRight(s.cfg.BaseURL, "/") + "/hooks/git/" + strconv.FormatInt(v.ID, 10)}) +} +func (s *Server) updateGitSource(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + var in gitops.Input + if e = read(r, &in); e != nil { + http.Error(w, e.Error(), 400) + return + } + v, e := s.git.Update(r.Context(), id, in) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) deleteGitSource(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + if e = s.git.Delete(r.Context(), id); e != nil { + http.Error(w, e.Error(), 500) + return + } + w.WriteHeader(204) +} +func (s *Server) syncGitSource(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + v, e := s.git.Sync(r.Context(), id) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) rotateGitSecret(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + secret, e := s.git.RotateSecret(r.Context(), id) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, map[string]string{"webhook_secret": secret}) +} +func (s *Server) gitWebhook(w http.ResponseWriter, r *http.Request) { + id, e := parseIntID(r) + if e != nil { + http.Error(w, "invalid id", 400) + return + } + body, e := io.ReadAll(io.LimitReader(r.Body, 2<<20)) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + sig := r.Header.Get("X-Hub-Signature-256") + tok := r.Header.Get("X-Gitlab-Token") + if tok == "" { + tok = r.Header.Get("X-Webhook-Token") + } + if e = s.git.VerifyWebhook(r.Context(), id, body, sig, tok); e != nil { + http.Error(w, "unauthorized webhook", 401) + return + } + v, e := s.git.Sync(r.Context(), id) + status := 200 + if e != nil { + status = 400 + } + detail := map[string]any{"git_source_id": id} + if e != nil { + detail["error"] = e.Error() + } else { + detail["stack"] = v.StackName + detail["commit"] = v.LastCommit + } + _ = s.audit.Log(r.Context(), audit.Entry{Actor: "git-webhook", Action: "git.sync", Resource: strconv.FormatInt(id, 10), Detail: detail, IP: r.RemoteAddr, UserAgent: r.UserAgent(), Status: status}) + if e != nil { + http.Error(w, e.Error(), status) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "source": v}) +} + +func (s *Server) stackGraph(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "GET", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"/graph", nil) + return + } + s.localGraph(w, r) +} +func (s *Server) localGraph(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.Graph(r.Context(), r.PathValue("name")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} +func (s *Server) stackImageUpdates(w http.ResponseWriter, r *http.Request) { + if id := nodeID(r); id > 0 { + s.relay(w, r, id, "GET", "/agent/v1/stacks/"+url.PathEscape(r.PathValue("name"))+"/image-updates", nil) + return + } + s.localImageUpdates(w, r) +} +func (s *Server) localImageUpdates(w http.ResponseWriter, r *http.Request) { + v, e := s.stacks.ImageUpdates(r.Context(), r.PathValue("name")) + if e != nil { + http.Error(w, e.Error(), 400) + return + } + jsonOut(w, 200, v) +} + +var wsUpgrader = websocket.Upgrader{ReadBufferSize: 4096, WriteBufferSize: 16384, CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + u, e := url.Parse(origin) + return e == nil && strings.EqualFold(u.Host, r.Host) +}} + +func (s *Server) stackTerminal(w http.ResponseWriter, r *http.Request) { + u, _ := auth.UserFrom(r.Context()) + actor := u.Name + if actor == "" { + actor = u.Email + } + uid := u.ID + _ = s.audit.Log(r.Context(), audit.Entry{UserID: &uid, Actor: actor, Action: "terminal.open", Resource: r.PathValue("name"), Detail: map[string]any{"node_id": nodeID(r), "service": r.URL.Query().Get("service")}, Status: 101}) + if id := nodeID(r); id > 0 { + s.proxyTerminal(w, r, id) + return + } + s.localTerminal(w, r) +} +func (s *Server) localTerminal(w http.ResponseWriter, r *http.Request) { + ws, e := wsUpgrader.Upgrade(w, r, nil) + if e != nil { + return + } + defer ws.Close() + service := r.URL.Query().Get("service") + shell := r.URL.Query().Get("shell") + _ = s.stacks.Terminal(r.Context(), r.PathValue("name"), service, shell, ws) +} +func (s *Server) proxyTerminal(w http.ResponseWriter, r *http.Request, id int64) { + client, e := wsUpgrader.Upgrade(w, r, nil) + if e != nil { + return + } + defer client.Close() + q := url.Values{} + q.Set("service", r.URL.Query().Get("service")) + q.Set("shell", r.URL.Query().Get("shell")) + path := "/agent/v1/stacks/" + url.PathEscape(r.PathValue("name")) + "/terminal?" + q.Encode() + agent, resp, e := s.nodes.DialWebSocket(r.Context(), id, path) + if e != nil { + msg := e.Error() + if resp != nil { + msg = resp.Status + } + _ = client.WriteJSON(stacks.TerminalMessage{Type: "error", Data: msg}) + return + } + defer agent.Close() + done := make(chan error, 2) + copyWS := func(dst, src *websocket.Conn) { + for { + typ, b, e := src.ReadMessage() + if e != nil { + done <- e + return + } + if e = dst.WriteMessage(typ, b); e != nil { + done <- e + return + } + } + } + go copyWS(agent, client) + go copyWS(client, agent) + select { + case <-r.Context().Done(): + case <-done: + } +} diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go new file mode 100644 index 0000000..62c579b --- /dev/null +++ b/internal/httpapi/httpapi_test.go @@ -0,0 +1,18 @@ +package httpapi + +import ( + "testing" + + "git.send.nrw/sendnrw/dockwatch/internal/audit" + "git.send.nrw/sendnrw/dockwatch/internal/auth" + "git.send.nrw/sendnrw/dockwatch/internal/config" +) + +func TestRouterPatternsDoNotConflict(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("ServeMux route conflict: %v", r) + } + }() + _ = New(config.Config{Mode: config.ModeStandalone}, &auth.Service{}, nil, nil, nil, (*audit.Service)(nil), nil, nil) +} diff --git a/internal/monitor/groups.go b/internal/monitor/groups.go new file mode 100644 index 0000000..fcfc2b9 --- /dev/null +++ b/internal/monitor/groups.go @@ -0,0 +1,481 @@ +package monitor + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +type ProbeGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Monitors []Monitor `json:"monitors,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type ProbeGroupInput struct { + Name string `json:"name"` + Description string `json:"description"` + MonitorIDs []int64 `json:"monitor_ids"` +} + +type StatusPage struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Status string `json:"status"` + Enabled bool `json:"enabled"` + ServiceIDs []int64 `json:"service_ids"` + Services []ProbeGroup `json:"services,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +type StatusPageInput struct { + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + Enabled *bool `json:"enabled"` + ServiceIDs []int64 `json:"service_ids"` +} + +var slugRx = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) + +func aggregateStatus(ms []Monitor) string { + if len(ms) == 0 { + return "unknown" + } + hasMaint, hasPending, hasPaused := false, false, false + for _, m := range ms { + switch m.Status { + case "down": + return "down" + case "maintenance": + hasMaint = true + case "pending": + hasPending = true + case "paused": + hasPaused = true + } + } + if hasMaint { + return "maintenance" + } + if hasPending { + return "pending" + } + if hasPaused { + return "paused" + } + return "up" +} + +func (s *Service) ListGroups(ctx context.Context) ([]ProbeGroup, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id,name,description,created_at,updated_at FROM monitor_services ORDER BY name`) + if err != nil { + return nil, err + } + out := []ProbeGroup{} + for rows.Next() { + var g ProbeGroup + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); err != nil { + _ = rows.Close() + return nil, err + } + out = append(out, g) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + + // Load monitors once and group in memory. This avoids an N+1 query pattern + // on the dashboard and keeps refresh cost predictable with many services. + all, err := s.List(ctx) + if err != nil { + return nil, err + } + byService := make(map[int64][]Monitor, len(out)) + for _, m := range all { + if m.ServiceID != nil { + byService[*m.ServiceID] = append(byService[*m.ServiceID], m) + } + } + for i := range out { + out[i].Monitors = byService[out[i].ID] + if out[i].Monitors == nil { + out[i].Monitors = []Monitor{} + } + out[i].Status = aggregateStatus(out[i].Monitors) + } + return out, nil +} + +func (s *Service) GetGroup(ctx context.Context, id int64) (ProbeGroup, error) { + var g ProbeGroup + if err := s.db.QueryRowContext(ctx, `SELECT id,name,description,created_at,updated_at FROM monitor_services WHERE id=?`, id).Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); err != nil { + return g, err + } + ms, err := s.listGroupMonitors(ctx, id) + if err != nil { + return g, err + } + g.Monitors = ms + g.Status = aggregateStatus(ms) + return g, nil +} + +func (s *Service) listGroupMonitors(ctx context.Context, id int64) ([]Monitor, error) { + rows, err := s.db.QueryContext(ctx, selectMonitor+` WHERE m.service_id=? ORDER BY m.name`, time.Now().Add(-24*time.Hour).Unix(), id) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Monitor{} + for rows.Next() { + m, e := scanMonitor(rows) + if e != nil { + return nil, e + } + out = append(out, m) + } + return out, rows.Err() +} + +func normalizeGroupInput(in *ProbeGroupInput) error { + in.Name = strings.TrimSpace(in.Name) + in.Description = strings.TrimSpace(in.Description) + if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") { + return errors.New("valid service name required") + } + if len(in.Description) > 2000 { + return errors.New("service description too long") + } + if in.MonitorIDs != nil { + seen := map[int64]bool{} + ids := make([]int64, 0, len(in.MonitorIDs)) + for _, id := range in.MonitorIDs { + if id < 1 { + return errors.New("monitor_ids must contain positive IDs") + } + if !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + in.MonitorIDs = ids + } + return nil +} + +func assignGroupMonitors(ctx context.Context, tx *sql.Tx, groupID int64, monitorIDs []int64, clearExisting bool) error { + if clearExisting { + if _, err := tx.ExecContext(ctx, `UPDATE monitors SET service_id=NULL,updated_at=? WHERE service_id=?`, time.Now().Unix(), groupID); err != nil { + return err + } + } + for _, monitorID := range monitorIDs { + r, err := tx.ExecContext(ctx, `UPDATE monitors SET service_id=?,updated_at=? WHERE id=?`, groupID, time.Now().Unix(), monitorID) + if err != nil { + return err + } + n, _ := r.RowsAffected() + if n == 0 { + return fmt.Errorf("monitor %d not found", monitorID) + } + } + return nil +} +func (s *Service) CreateGroup(ctx context.Context, in ProbeGroupInput) (ProbeGroup, error) { + if err := normalizeGroupInput(&in); err != nil { + return ProbeGroup{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return ProbeGroup{}, err + } + defer tx.Rollback() + now := time.Now().Unix() + r, err := tx.ExecContext(ctx, `INSERT INTO monitor_services(name,description,created_at,updated_at) VALUES(?,?,?,?)`, in.Name, in.Description, now, now) + if err != nil { + return ProbeGroup{}, err + } + id, _ := r.LastInsertId() + if in.MonitorIDs != nil { + if err := assignGroupMonitors(ctx, tx, id, in.MonitorIDs, false); err != nil { + return ProbeGroup{}, err + } + } + if err := tx.Commit(); err != nil { + return ProbeGroup{}, err + } + return s.GetGroup(ctx, id) +} +func (s *Service) UpdateGroup(ctx context.Context, id int64, in ProbeGroupInput) (ProbeGroup, error) { + if err := normalizeGroupInput(&in); err != nil { + return ProbeGroup{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return ProbeGroup{}, err + } + defer tx.Rollback() + r, err := tx.ExecContext(ctx, `UPDATE monitor_services SET name=?,description=?,updated_at=? WHERE id=?`, in.Name, in.Description, time.Now().Unix(), id) + if err != nil { + return ProbeGroup{}, err + } + n, _ := r.RowsAffected() + if n == 0 { + return ProbeGroup{}, sql.ErrNoRows + } + if in.MonitorIDs != nil { + if err := assignGroupMonitors(ctx, tx, id, in.MonitorIDs, true); err != nil { + return ProbeGroup{}, err + } + } + if err := tx.Commit(); err != nil { + return ProbeGroup{}, err + } + return s.GetGroup(ctx, id) +} +func (s *Service) DeleteGroup(ctx context.Context, id int64) error { + _, e := s.db.ExecContext(ctx, `DELETE FROM monitor_services WHERE id=?`, id) + return e +} + +func normalizeStatusPage(in *StatusPageInput) error { + in.Name = strings.TrimSpace(in.Name) + in.Slug = strings.ToLower(strings.TrimSpace(in.Slug)) + in.Description = strings.TrimSpace(in.Description) + if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") || !slugRx.MatchString(in.Slug) { + return errors.New("name and valid slug required (lowercase letters, numbers, hyphens)") + } + if len(in.Description) > 4000 { + return errors.New("status page description too long") + } + seen := map[int64]bool{} + var ids []int64 + for _, id := range in.ServiceIDs { + if id > 0 && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + in.ServiceIDs = ids + return nil +} +func (s *Service) ListStatusPages(ctx context.Context) ([]StatusPage, error) { + rows, e := s.db.QueryContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages ORDER BY name`) + if e != nil { + return nil, e + } + out := []StatusPage{} + for rows.Next() { + var p StatusPage + if e = rows.Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt); e != nil { + _ = rows.Close() + return nil, e + } + p.ServiceIDs = []int64{} + out = append(out, p) + } + if e = rows.Err(); e != nil { + _ = rows.Close() + return nil, e + } + if e = rows.Close(); e != nil { + return nil, e + } + + // Resolve page memberships only after releasing the one SQLite connection. + for i := range out { + out[i].ServiceIDs, e = s.statusPageServiceIDs(ctx, out[i].ID) + if e != nil { + return nil, e + } + } + return out, nil +} +func (s *Service) GetStatusPage(ctx context.Context, id int64) (StatusPage, error) { + var p StatusPage + e := s.db.QueryRowContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages WHERE id=?`, id).Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt) + if e != nil { + return p, e + } + p.ServiceIDs, e = s.statusPageServiceIDs(ctx, id) + return p, e +} +func (s *Service) statusPageServiceIDs(ctx context.Context, id int64) ([]int64, error) { + rows, e := s.db.QueryContext(ctx, `SELECT service_id FROM status_page_services WHERE page_id=? ORDER BY sort_order,service_id`, id) + if e != nil { + return nil, e + } + defer rows.Close() + out := []int64{} + for rows.Next() { + var x int64 + if e = rows.Scan(&x); e != nil { + return nil, e + } + out = append(out, x) + } + return out, rows.Err() +} +func (s *Service) savePageServices(ctx context.Context, tx *sql.Tx, id int64, ids []int64) error { + if _, e := tx.ExecContext(ctx, `DELETE FROM status_page_services WHERE page_id=?`, id); e != nil { + return e + } + for i, sid := range ids { + if _, e := tx.ExecContext(ctx, `INSERT INTO status_page_services(page_id,service_id,sort_order) VALUES(?,?,?)`, id, sid, i); e != nil { + return e + } + } + return nil +} +func (s *Service) CreateStatusPage(ctx context.Context, in StatusPageInput) (StatusPage, error) { + if e := normalizeStatusPage(&in); e != nil { + return StatusPage{}, e + } + en := true + if in.Enabled != nil { + en = *in.Enabled + } + tx, e := s.db.BeginTx(ctx, nil) + if e != nil { + return StatusPage{}, e + } + defer tx.Rollback() + now := time.Now().Unix() + r, e := tx.ExecContext(ctx, `INSERT INTO status_pages(name,slug,description,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)`, in.Name, in.Slug, in.Description, en, now, now) + if e != nil { + return StatusPage{}, e + } + id, _ := r.LastInsertId() + if e = s.savePageServices(ctx, tx, id, in.ServiceIDs); e != nil { + return StatusPage{}, e + } + if e = tx.Commit(); e != nil { + return StatusPage{}, e + } + return s.GetStatusPage(ctx, id) +} +func (s *Service) UpdateStatusPage(ctx context.Context, id int64, in StatusPageInput) (StatusPage, error) { + if e := normalizeStatusPage(&in); e != nil { + return StatusPage{}, e + } + old, e := s.GetStatusPage(ctx, id) + if e != nil { + return StatusPage{}, e + } + en := old.Enabled + if in.Enabled != nil { + en = *in.Enabled + } + tx, e := s.db.BeginTx(ctx, nil) + if e != nil { + return StatusPage{}, e + } + defer tx.Rollback() + if _, e = tx.ExecContext(ctx, `UPDATE status_pages SET name=?,slug=?,description=?,enabled=?,updated_at=? WHERE id=?`, in.Name, in.Slug, in.Description, en, time.Now().Unix(), id); e != nil { + return StatusPage{}, e + } + if e = s.savePageServices(ctx, tx, id, in.ServiceIDs); e != nil { + return StatusPage{}, e + } + if e = tx.Commit(); e != nil { + return StatusPage{}, e + } + return s.GetStatusPage(ctx, id) +} +func (s *Service) DeleteStatusPage(ctx context.Context, id int64) error { + _, e := s.db.ExecContext(ctx, `DELETE FROM status_pages WHERE id=?`, id) + return e +} +func (s *Service) PublicStatusPage(ctx context.Context, slug string) (StatusPage, error) { + p := StatusPage{Services: []ProbeGroup{}, ServiceIDs: []int64{}} + e := s.db.QueryRowContext(ctx, `SELECT id,name,slug,description,enabled,created_at,updated_at FROM status_pages WHERE slug=? AND enabled=1`, slug).Scan(&p.ID, &p.Name, &p.Slug, &p.Description, &p.Enabled, &p.CreatedAt, &p.UpdatedAt) + if e != nil { + return p, e + } + rows, e := s.db.QueryContext(ctx, `SELECT ms.id,ms.name,ms.description,ms.created_at,ms.updated_at FROM monitor_services ms JOIN status_page_services ps ON ps.service_id=ms.id WHERE ps.page_id=? ORDER BY ps.sort_order,ms.name`, p.ID) + if e != nil { + return p, e + } + for rows.Next() { + var g ProbeGroup + if e = rows.Scan(&g.ID, &g.Name, &g.Description, &g.CreatedAt, &g.UpdatedAt); e != nil { + _ = rows.Close() + return p, e + } + g.Monitors = []Monitor{} + p.Services = append(p.Services, g) + p.ServiceIDs = append(p.ServiceIDs, g.ID) + } + if e = rows.Err(); e != nil { + _ = rows.Close() + return p, e + } + if e = rows.Close(); e != nil { + return p, e + } + + all, e := s.List(ctx) + if e != nil { + return p, e + } + byService := map[int64][]Monitor{} + for _, m := range all { + if m.ServiceID != nil { + byService[*m.ServiceID] = append(byService[*m.ServiceID], m) + } + } + for gi := range p.Services { + p.Services[gi].Monitors = byService[p.Services[gi].ID] + if p.Services[gi].Monitors == nil { + p.Services[gi].Monitors = []Monitor{} + } + p.Services[gi].Status = aggregateStatus(p.Services[gi].Monitors) + } + p.Status = aggregateServiceStatuses(p.Services) + return p, nil +} + +func aggregateServiceStatuses(groups []ProbeGroup) string { + if len(groups) == 0 { + return "unknown" + } + hasMaint, hasPending, hasPaused := false, false, false + for _, g := range groups { + switch g.Status { + case "down": + return "down" + case "maintenance": + hasMaint = true + case "pending", "unknown": + hasPending = true + case "paused": + hasPaused = true + } + } + if hasMaint { + return "maintenance" + } + if hasPending { + return "pending" + } + if hasPaused { + return "paused" + } + return "up" +} diff --git a/internal/monitor/groups_connection_test.go b/internal/monitor/groups_connection_test.go new file mode 100644 index 0000000..3158199 --- /dev/null +++ b/internal/monitor/groups_connection_test.go @@ -0,0 +1,121 @@ +package monitor + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "strings" + "sync" + "testing" + "time" +) + +var registerSingleConnDriver sync.Once + +func openSingleConnRegressionDB(t *testing.T) *sql.DB { + t.Helper() + registerSingleConnDriver.Do(func() { sql.Register("dockwatch-singleconn-regression", singleConnDriver{}) }) + db, err := sql.Open("dockwatch-singleconn-regression", "") + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +type singleConnDriver struct{} + +func (singleConnDriver) Open(string) (driver.Conn, error) { return &singleConn{}, nil } + +type singleConn struct{} + +func (*singleConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("prepare not supported") +} +func (*singleConn) Close() error { return nil } +func (*singleConn) Begin() (driver.Tx, error) { return nil, errors.New("tx not supported") } +func (*singleConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) { + switch { + case strings.Contains(query, "FROM monitor_services ORDER BY name"): + return newStaticRows([]string{"id", "name", "description", "created_at", "updated_at"}, [][]driver.Value{{int64(1), "Website", "", int64(1), int64(1)}}), nil + case strings.Contains(query, "FROM status_pages ORDER BY name"): + return newStaticRows([]string{"id", "name", "slug", "description", "enabled", "created_at", "updated_at"}, [][]driver.Value{{int64(1), "Public", "public", "", int64(1), int64(1), int64(1)}}), nil + case strings.Contains(query, "FROM status_pages WHERE slug="): + return newStaticRows([]string{"id", "name", "slug", "description", "enabled", "created_at", "updated_at"}, [][]driver.Value{{int64(1), "Public", "public", "", int64(1), int64(1), int64(1)}}), nil + case strings.Contains(query, "FROM status_page_services WHERE page_id="): + return newStaticRows([]string{"service_id"}, [][]driver.Value{{int64(1)}}), nil + case strings.Contains(query, "JOIN status_page_services"): + return newStaticRows([]string{"id", "name", "description", "created_at", "updated_at"}, [][]driver.Value{{int64(1), "Website", "", int64(1), int64(1)}}), nil + case strings.Contains(query, "FROM monitors m"): + // The regression only needs to verify that this second query can start + // after the service rows were released. No monitor row is required. + return newStaticRows([]string{"monitor"}, nil), nil + default: + return nil, errors.New("unexpected query: " + query) + } +} + +var _ driver.QueryerContext = (*singleConn)(nil) + +type staticRows struct { + cols []string + data [][]driver.Value + pos int +} + +func newStaticRows(cols []string, data [][]driver.Value) *staticRows { + return &staticRows{cols: cols, data: data} +} +func (r *staticRows) Columns() []string { return r.cols } +func (r *staticRows) Close() error { return nil } +func (r *staticRows) Next(dest []driver.Value) error { + if r.pos >= len(r.data) { + return io.EOF + } + copy(dest, r.data[r.pos]) + r.pos++ + return nil +} + +func shortContext(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + t.Cleanup(cancel) + return ctx +} + +func TestListGroupsDoesNotNestQueriesOnSingleConnection(t *testing.T) { + s := &Service{db: openSingleConnRegressionDB(t)} + groups, err := s.ListGroups(shortContext(t)) + if err != nil { + t.Fatalf("ListGroups: %v", err) + } + if len(groups) != 1 || groups[0].Name != "Website" { + t.Fatalf("unexpected groups: %#v", groups) + } +} + +func TestListStatusPagesDoesNotNestQueriesOnSingleConnection(t *testing.T) { + s := &Service{db: openSingleConnRegressionDB(t)} + pages, err := s.ListStatusPages(shortContext(t)) + if err != nil { + t.Fatalf("ListStatusPages: %v", err) + } + if len(pages) != 1 || len(pages[0].ServiceIDs) != 1 || pages[0].ServiceIDs[0] != 1 { + t.Fatalf("unexpected pages: %#v", pages) + } +} + +func TestPublicStatusPageDoesNotNestQueriesOnSingleConnection(t *testing.T) { + s := &Service{db: openSingleConnRegressionDB(t)} + page, err := s.PublicStatusPage(shortContext(t), "public") + if err != nil { + t.Fatalf("PublicStatusPage: %v", err) + } + if len(page.Services) != 1 || page.Services[0].Name != "Website" { + t.Fatalf("unexpected public page: %#v", page) + } +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..991243c --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,659 @@ +package monitor + +import ( + "context" + "crypto/tls" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "git.send.nrw/sendnrw/dockwatch/internal/buildinfo" + "git.send.nrw/sendnrw/dockwatch/internal/nodes" +) + +type Monitor struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + NodeID *int64 `json:"node_id,omitempty"` + ServiceID *int64 `json:"service_id,omitempty"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutMS int `json:"timeout_ms"` + ExpectedMin int `json:"expected_min"` + ExpectedMax int `json:"expected_max"` + Method string `json:"method"` + HeadersJSON string `json:"headers_json"` + Body string `json:"body"` + Keyword string `json:"keyword"` + InvertKeyword bool `json:"invert_keyword"` + IgnoreTLS bool `json:"ignore_tls"` + RequireHealthy bool `json:"require_healthy"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + MaintenanceUntil *int64 `json:"maintenance_until,omitempty"` + MaintenanceNote string `json:"maintenance_note"` + LastCheckedAt *int64 `json:"last_checked_at,omitempty"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + Uptime24h float64 `json:"uptime_24h"` + LastLatencyMS int64 `json:"last_latency_ms"` + LastMessage string `json:"last_message"` + LastStatusCode int `json:"last_status_code"` +} + +type Input struct { + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + NodeID *int64 `json:"node_id"` + ServiceID *int64 `json:"service_id"` + IntervalSeconds int `json:"interval_seconds"` + TimeoutMS int `json:"timeout_ms"` + ExpectedMin int `json:"expected_min"` + ExpectedMax int `json:"expected_max"` + Method string `json:"method"` + HeadersJSON string `json:"headers_json"` + Body string `json:"body"` + Keyword string `json:"keyword"` + InvertKeyword bool `json:"invert_keyword"` + IgnoreTLS bool `json:"ignore_tls"` + RequireHealthy bool `json:"require_healthy"` + Enabled *bool `json:"enabled"` +} + +type MaintenanceInput struct { + Until *int64 `json:"until"` + Note string `json:"note"` +} +type Check struct { + ID int64 `json:"id,omitempty"` + MonitorID int64 `json:"monitor_id,omitempty"` + OK bool `json:"ok"` + StatusCode int `json:"status_code"` + LatencyMS int64 `json:"latency_ms"` + Message string `json:"message"` + CheckedAt int64 `json:"checked_at"` +} +type Event struct { + MonitorID int64 `json:"monitor_id"` + Name string `json:"name"` + Target string `json:"target"` + From string `json:"from"` + To string `json:"to"` + Check Check `json:"check"` +} + +type Service struct { + db *sql.DB + nodes *nodes.Manager + workers chan struct{} + mu sync.Mutex + running map[int64]bool + retentionDays int + eventSink func(context.Context, Event) +} + +func New(db *sql.DB, nm *nodes.Manager, c, r int) *Service { + return &Service{db: db, nodes: nm, workers: make(chan struct{}, c), running: map[int64]bool{}, retentionDays: r} +} + +func (s *Service) SetEventSink(fn func(context.Context, Event)) { s.eventSink = fn } + +const selectMonitor = `WITH stats AS ( + SELECT monitor_id,100.0*AVG(ok) AS uptime_24h FROM monitor_checks WHERE checked_at>=? GROUP BY monitor_id +), latest AS ( + SELECT monitor_id,MAX(id) AS id FROM monitor_checks GROUP BY monitor_id +) +SELECT m.id,m.name,m.type,m.target,m.node_id,m.service_id,m.interval_seconds,m.timeout_ms,m.expected_min,m.expected_max,m.method,m.headers_json,m.body,m.keyword,m.invert_keyword,m.ignore_tls,m.require_healthy,m.enabled,m.status,m.maintenance_until,m.maintenance_note,m.last_checked_at,m.created_at,m.updated_at, + COALESCE(stats.uptime_24h,0),COALESCE(c.latency_ms,0),COALESCE(c.message,''),COALESCE(c.status_code,0) +FROM monitors m +LEFT JOIN stats ON stats.monitor_id=m.id +LEFT JOIN latest ON latest.monitor_id=m.id +LEFT JOIN monitor_checks c ON c.id=latest.id` + +func scanMonitor(sc interface{ Scan(...any) error }) (Monitor, error) { + var m Monitor + var node, serviceID, last, maint sql.NullInt64 + err := sc.Scan(&m.ID, &m.Name, &m.Type, &m.Target, &node, &serviceID, &m.IntervalSeconds, &m.TimeoutMS, &m.ExpectedMin, &m.ExpectedMax, &m.Method, &m.HeadersJSON, &m.Body, &m.Keyword, &m.InvertKeyword, &m.IgnoreTLS, &m.RequireHealthy, &m.Enabled, &m.Status, &maint, &m.MaintenanceNote, &last, &m.CreatedAt, &m.UpdatedAt, &m.Uptime24h, &m.LastLatencyMS, &m.LastMessage, &m.LastStatusCode) + if node.Valid { + m.NodeID = &node.Int64 + } + if serviceID.Valid { + m.ServiceID = &serviceID.Int64 + } + if last.Valid { + m.LastCheckedAt = &last.Int64 + } + if maint.Valid { + m.MaintenanceUntil = &maint.Int64 + } + return m, err +} +func (s *Service) List(ctx context.Context) ([]Monitor, error) { + rows, e := s.db.QueryContext(ctx, selectMonitor+` ORDER BY m.name`, time.Now().Add(-24*time.Hour).Unix()) + if e != nil { + return nil, e + } + defer rows.Close() + out := []Monitor{} + for rows.Next() { + m, e := scanMonitor(rows) + if e != nil { + return nil, e + } + out = append(out, m) + } + return out, rows.Err() +} +func (s *Service) Get(ctx context.Context, id int64) (Monitor, error) { + return scanMonitor(s.db.QueryRowContext(ctx, selectMonitor+` WHERE m.id=?`, time.Now().Add(-24*time.Hour).Unix(), id)) +} + +const selectMonitorSchedule = `SELECT m.id,m.name,m.type,m.target,m.node_id,m.service_id,m.interval_seconds,m.timeout_ms,m.expected_min,m.expected_max,m.method,m.headers_json,m.body,m.keyword,m.invert_keyword,m.ignore_tls,m.require_healthy,m.enabled,m.status,m.maintenance_until,m.maintenance_note,m.last_checked_at,m.created_at,m.updated_at,0.0,0,'',0 FROM monitors m ORDER BY m.id` + +func (s *Service) listForSchedule(ctx context.Context) ([]Monitor, error) { + rows, err := s.db.QueryContext(ctx, selectMonitorSchedule) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Monitor{} + for rows.Next() { + m, err := scanMonitor(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} +func normalize(in *Input, requireName bool) error { + in.Name = strings.TrimSpace(in.Name) + in.Type = strings.ToLower(strings.TrimSpace(in.Type)) + in.Target = strings.TrimSpace(in.Target) + if in.Target == "" { + return errors.New("target required") + } + if requireName && in.Name == "" { + return errors.New("name required") + } + if strings.ContainsAny(in.Name, "\r\n") { + return errors.New("invalid monitor name") + } + if len(in.Name) > 200 || len(in.Target) > 4096 { + return errors.New("name or target too long") + } + if in.Type != "http" && in.Type != "tcp" && in.Type != "dns" && in.Type != "docker" { + return errors.New("type must be http, tcp, dns or docker") + } + if in.NodeID != nil && *in.NodeID < 1 { + return errors.New("node_id must be positive") + } + if in.ServiceID != nil && *in.ServiceID < 1 { + return errors.New("service_id must be positive") + } + if in.IntervalSeconds == 0 { + in.IntervalSeconds = 60 + } + if in.IntervalSeconds < 10 || in.IntervalSeconds > 86400 { + return errors.New("interval_seconds must be 10..86400") + } + if in.TimeoutMS == 0 { + in.TimeoutMS = 5000 + } + if in.TimeoutMS < 100 || in.TimeoutMS > 60000 { + return errors.New("timeout_ms must be 100..60000") + } + if len(in.HeadersJSON) > 64<<10 || len(in.Body) > 256<<10 || len(in.Keyword) > 4096 { + return errors.New("monitor headers/body/keyword too large") + } + in.Method = strings.ToUpper(strings.TrimSpace(in.Method)) + if in.Method == "" { + in.Method = "GET" + } + if in.HeadersJSON == "" { + in.HeadersJSON = "{}" + } + var h map[string]string + if err := json.Unmarshal([]byte(in.HeadersJSON), &h); err != nil { + return errors.New("headers_json must be a JSON object with string values") + } + for k, v := range h { + if strings.TrimSpace(k) == "" || strings.ContainsAny(k, "\r\n") || strings.ContainsAny(v, "\r\n") { + return errors.New("HTTP headers must not contain empty names or newlines") + } + } + switch in.Type { + case "http": + u, err := url.Parse(in.Target) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return errors.New("HTTP target must be an absolute http(s) URL without embedded credentials") + } + allowed := map[string]bool{"GET": true, "HEAD": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true, "OPTIONS": true} + if !allowed[in.Method] { + return errors.New("unsupported HTTP method") + } + if in.ExpectedMin == 0 { + in.ExpectedMin = 200 + } + if in.ExpectedMax == 0 { + in.ExpectedMax = 399 + } + if in.ExpectedMin < 100 || in.ExpectedMax > 599 || in.ExpectedMin > in.ExpectedMax { + return errors.New("expected HTTP status range must be within 100..599") + } + case "tcp": + host, port, err := net.SplitHostPort(in.Target) + if err != nil || strings.TrimSpace(host) == "" || strings.TrimSpace(port) == "" { + return errors.New("TCP target must be host:port") + } + if p, err := strconv.Atoi(port); err != nil || p < 1 || p > 65535 { + return errors.New("TCP target port must be 1..65535") + } + case "dns": + if strings.ContainsAny(in.Target, " /\\") { + return errors.New("DNS target must be a hostname or IP address") + } + case "docker": + if len(in.Target) > 255 || strings.ContainsAny(in.Target, "\r\n") || strings.HasPrefix(in.Target, "-") { + return errors.New("invalid Docker container target") + } + } + return nil +} +func (s *Service) Create(ctx context.Context, in Input, userID int64) (Monitor, error) { + if e := normalize(&in, true); e != nil { + return Monitor{}, e + } + enabled := true + if in.Enabled != nil { + enabled = *in.Enabled + } + now := time.Now().Unix() + res, e := s.db.ExecContext(ctx, `INSERT INTO monitors(name,type,target,node_id,service_id,interval_seconds,timeout_ms,expected_min,expected_max,method,headers_json,body,keyword,invert_keyword,ignore_tls,require_healthy,enabled,status,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'pending',?,?,?)`, in.Name, in.Type, in.Target, in.NodeID, in.ServiceID, in.IntervalSeconds, in.TimeoutMS, in.ExpectedMin, in.ExpectedMax, in.Method, in.HeadersJSON, in.Body, in.Keyword, in.InvertKeyword, in.IgnoreTLS, in.RequireHealthy, enabled, userID, now, now) + if e != nil { + return Monitor{}, e + } + id, _ := res.LastInsertId() + return s.Get(ctx, id) +} +func (s *Service) Update(ctx context.Context, id int64, in Input) (Monitor, error) { + if e := normalize(&in, true); e != nil { + return Monitor{}, e + } + enabled := true + if in.Enabled != nil { + enabled = *in.Enabled + } + now := time.Now().Unix() + res, e := s.db.ExecContext(ctx, `UPDATE monitors SET name=?,type=?,target=?,node_id=?,service_id=?,interval_seconds=?,timeout_ms=?,expected_min=?,expected_max=?,method=?,headers_json=?,body=?,keyword=?,invert_keyword=?,ignore_tls=?,require_healthy=?,enabled=?,updated_at=? WHERE id=?`, in.Name, in.Type, in.Target, in.NodeID, in.ServiceID, in.IntervalSeconds, in.TimeoutMS, in.ExpectedMin, in.ExpectedMax, in.Method, in.HeadersJSON, in.Body, in.Keyword, in.InvertKeyword, in.IgnoreTLS, in.RequireHealthy, enabled, now, id) + if e != nil { + return Monitor{}, e + } + n, _ := res.RowsAffected() + if n == 0 { + return Monitor{}, sql.ErrNoRows + } + if !enabled { + _, _ = s.db.ExecContext(ctx, `UPDATE monitors SET status='paused' WHERE id=?`, id) + } else { + _, _ = s.db.ExecContext(ctx, `UPDATE monitors SET status=CASE WHEN status='paused' THEN 'pending' ELSE status END WHERE id=?`, id) + } + return s.Get(ctx, id) +} +func (s *Service) SetPaused(ctx context.Context, id int64, paused bool) error { + enabled := !paused + status := "pending" + if paused { + status = "paused" + } + res, e := s.db.ExecContext(ctx, `UPDATE monitors SET enabled=?,status=?,updated_at=? WHERE id=?`, enabled, status, time.Now().Unix(), id) + if e != nil { + return e + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} +func (s *Service) SetMaintenance(ctx context.Context, id int64, in MaintenanceInput) error { + now := time.Now().Unix() + if in.Until != nil && *in.Until <= now { + return errors.New("maintenance end must be in the future") + } + in.Note = strings.TrimSpace(in.Note) + if len(in.Note) > 2000 { + return errors.New("maintenance note too long") + } + res, e := s.db.ExecContext(ctx, `UPDATE monitors SET maintenance_until=?,maintenance_note=?,status='maintenance',updated_at=? WHERE id=?`, in.Until, in.Note, now, id) + if e != nil { + return e + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} +func (s *Service) ClearMaintenance(ctx context.Context, id int64) error { + r, e := s.db.ExecContext(ctx, `UPDATE monitors SET maintenance_until=NULL,maintenance_note='',status=CASE WHEN enabled=1 THEN 'pending' ELSE 'paused' END,updated_at=? WHERE id=?`, time.Now().Unix(), id) + if e != nil { + return e + } + n, _ := r.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} +func (s *Service) Delete(ctx context.Context, id int64) error { + _, e := s.db.ExecContext(ctx, `DELETE FROM monitors WHERE id=?`, id) + return e +} +func (s *Service) Checks(ctx context.Context, id int64, limit int) ([]Check, error) { + if limit < 1 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + rows, e := s.db.QueryContext(ctx, `SELECT id,monitor_id,ok,status_code,latency_ms,message,checked_at FROM monitor_checks WHERE monitor_id=? ORDER BY checked_at DESC LIMIT ?`, id, limit) + if e != nil { + return nil, e + } + defer rows.Close() + out := []Check{} + for rows.Next() { + var c Check + if e := rows.Scan(&c.ID, &c.MonitorID, &c.OK, &c.StatusCode, &c.LatencyMS, &c.Message, &c.CheckedAt); e != nil { + return nil, e + } + out = append(out, c) + } + return out, rows.Err() +} +func (s *Service) Run(ctx context.Context) { + tick := time.NewTicker(2 * time.Second) + cleanup := time.NewTicker(6 * time.Hour) + defer tick.Stop() + defer cleanup.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + s.schedule(ctx) + case <-cleanup.C: + s.cleanup(ctx) + } + } +} +func (s *Service) schedule(ctx context.Context) { + ms, e := s.listForSchedule(ctx) + if e != nil { + return + } + now := time.Now().Unix() + for _, m := range ms { + if !m.Enabled { + continue + } + if m.Status == "maintenance" && m.MaintenanceUntil == nil { + continue + } + if m.MaintenanceUntil != nil { + if *m.MaintenanceUntil == 0 || *m.MaintenanceUntil > now { + if m.Status != "maintenance" { + _, _ = s.db.ExecContext(ctx, `UPDATE monitors SET status='maintenance' WHERE id=?`, m.ID) + } + continue + } + _ = s.ClearMaintenance(ctx, m.ID) + } + if m.LastCheckedAt != nil && now-*m.LastCheckedAt < int64(m.IntervalSeconds) { + continue + } + s.mu.Lock() + if s.running[m.ID] { + s.mu.Unlock() + continue + } + s.running[m.ID] = true + s.mu.Unlock() + go func(mon Monitor) { + select { + case s.workers <- struct{}{}: + case <-ctx.Done(): + s.mu.Lock() + delete(s.running, mon.ID) + s.mu.Unlock() + return + } + defer func() { <-s.workers; s.mu.Lock(); delete(s.running, mon.ID); s.mu.Unlock() }() + c := s.probe(ctx, mon) + if ctx.Err() != nil { + return + } + x, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = s.recordCheck(x, mon, c) + }(m) + } +} +func (s *Service) CheckNow(ctx context.Context, id int64) (Check, error) { + s.mu.Lock() + if s.running[id] { + s.mu.Unlock() + return Check{}, errors.New("monitor check already running") + } + s.running[id] = true + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.running, id) + s.mu.Unlock() + }() + m, err := s.Get(ctx, id) + if err != nil { + return Check{}, err + } + c := s.probe(ctx, m) + if err := ctx.Err(); err != nil { + return c, err + } + // A manual diagnostic check must not implicitly resume a paused monitor or + // end maintenance. We still keep the check in history, but preserve the + // lifecycle state until the user explicitly changes it. + updateState := m.Enabled && m.Status != "maintenance" + if err = s.recordCheckResult(ctx, m, c, updateState); err != nil { + return c, err + } + return c, nil +} + +func (s *Service) recordCheck(ctx context.Context, m Monitor, c Check) error { + return s.recordCheckResult(ctx, m, c, true) +} + +func (s *Service) recordCheckResult(ctx context.Context, m Monitor, c Check, updateState bool) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `INSERT INTO monitor_checks(monitor_id,ok,status_code,latency_ms,message,checked_at) VALUES(?,?,?,?,?,?)`, m.ID, c.OK, c.StatusCode, c.LatencyMS, c.Message, c.CheckedAt); err != nil { + return err + } + status := "down" + if c.OK { + status = "up" + } + if updateState { + if _, err = tx.ExecContext(ctx, `UPDATE monitors SET status=?,last_checked_at=?,updated_at=? WHERE id=?`, status, c.CheckedAt, c.CheckedAt, m.ID); err != nil { + return err + } + } else { + if _, err = tx.ExecContext(ctx, `UPDATE monitors SET last_checked_at=?,updated_at=? WHERE id=?`, c.CheckedAt, c.CheckedAt, m.ID); err != nil { + return err + } + } + if err = tx.Commit(); err != nil { + return err + } + if updateState && s.eventSink != nil && (m.Status == "up" || m.Status == "down") && m.Status != status { + s.eventSink(context.Background(), Event{MonitorID: m.ID, Name: m.Name, Target: m.Target, From: m.Status, To: status, Check: c}) + } + return nil +} + +func (s *Service) probe(ctx context.Context, m Monitor) Check { + in := Input{Name: m.Name, Type: m.Type, Target: m.Target, TimeoutMS: m.TimeoutMS, ExpectedMin: m.ExpectedMin, ExpectedMax: m.ExpectedMax, Method: m.Method, HeadersJSON: m.HeadersJSON, Body: m.Body, Keyword: m.Keyword, InvertKeyword: m.InvertKeyword, IgnoreTLS: m.IgnoreTLS, RequireHealthy: m.RequireHealthy} + if m.NodeID != nil { + b, _, e := s.nodes.Do(ctx, *m.NodeID, http.MethodPost, "/agent/v1/probe", in) + if e != nil { + return Check{Message: e.Error(), CheckedAt: time.Now().Unix()} + } + var c Check + if json.Unmarshal(b, &c) != nil { + return Check{Message: "invalid agent response", CheckedAt: time.Now().Unix()} + } + return c + } + return Probe(ctx, in) +} +func Probe(ctx context.Context, in Input) Check { + start := time.Now() + c := Check{CheckedAt: start.Unix()} + if err := normalize(&in, false); err != nil { + c.Message = err.Error() + return c + } + pctx, cancel := context.WithTimeout(ctx, time.Duration(in.TimeoutMS)*time.Millisecond) + defer cancel() + switch in.Type { + case "http": + tr := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: in.IgnoreTLS}} + defer tr.CloseIdleConnections() + client := &http.Client{Transport: tr} + req, e := http.NewRequestWithContext(pctx, in.Method, in.Target, strings.NewReader(in.Body)) + if e != nil { + c.Message = e.Error() + return c + } + var headers map[string]string + _ = json.Unmarshal([]byte(in.HeadersJSON), &headers) + for k, v := range headers { + req.Header.Set(k, v) + } + req.Header.Set("User-Agent", "Dockwatch/"+buildinfo.Current().Version) + resp, e := client.Do(req) + if e != nil { + c.Message = e.Error() + return c + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512<<10)) + c.StatusCode = resp.StatusCode + c.OK = resp.StatusCode >= in.ExpectedMin && resp.StatusCode <= in.ExpectedMax + if c.OK && in.Keyword != "" { + found := strings.Contains(string(body), in.Keyword) + if in.InvertKeyword { + found = !found + } + c.OK = found + if !found { + c.Message = "keyword assertion failed" + } + } + if !c.OK && c.Message == "" { + c.Message = "unexpected HTTP status" + } + case "tcp": + conn, e := (&net.Dialer{}).DialContext(pctx, "tcp", in.Target) + if e != nil { + c.Message = e.Error() + return c + } + _ = conn.Close() + c.OK = true + case "docker": + cmd := exec.CommandContext(pctx, "docker", "inspect", "--format", "{{json .State}}", in.Target) + b, e := cmd.Output() + if e != nil { + c.Message = "docker inspect: " + e.Error() + return c + } + var st struct { + Running bool `json:"Running"` + Status string `json:"Status"` + Health *struct { + Status string `json:"Status"` + } `json:"Health"` + } + if e = json.Unmarshal(bytesTrimSpace(b), &st); e != nil { + c.Message = "invalid docker state: " + e.Error() + return c + } + if !st.Running { + c.Message = "container is not running (" + st.Status + ")" + return c + } + if in.RequireHealthy { + if st.Health == nil { + c.Message = "container has no healthcheck" + return c + } + if strings.ToLower(st.Health.Status) != "healthy" { + c.Message = "container health is " + st.Health.Status + return c + } + } + c.OK = true + c.Message = "container running" + if in.RequireHealthy { + c.Message = "container running and healthy" + } + case "dns": + _, e := net.DefaultResolver.LookupHost(pctx, in.Target) + if e != nil { + c.Message = e.Error() + return c + } + c.OK = true + default: + c.Message = "unsupported monitor type" + } + c.LatencyMS = time.Since(start).Milliseconds() + return c +} +func (s *Service) cleanup(ctx context.Context) { + // Expired authentication sessions are always disposable, even when heartbeat + // retention is configured as unlimited (0). + _, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at 120 || strings.ContainsAny(name, "\r\n") || len(token) < 24 { + return Node{}, errors.New("valid name and token (>=24 chars) required") + } + if err := validateBaseURL(baseURL); err != nil { + return Node{}, err + } + enc, err := m.encrypt([]byte(token)) + if err != nil { + return Node{}, err + } + now := time.Now().Unix() + res, err := m.db.ExecContext(ctx, `INSERT INTO nodes(name,base_url,token_enc,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)`, name, baseURL, enc, 1, now, now) + if err != nil { + return Node{}, err + } + id, _ := res.LastInsertId() + return Node{ID: id, Name: name, BaseURL: baseURL, Enabled: true, CreatedAt: now, UpdatedAt: now}, nil +} + +func (m *Manager) Update(ctx context.Context, id int64, name, baseURL, token string, enabled *bool) (Node, error) { + old, err := m.get(ctx, id) + if err != nil { + return Node{}, err + } + name = strings.TrimSpace(name) + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if name == "" { + name = old.Name + } + if len(name) > 120 || strings.ContainsAny(name, "\r\n") { + return Node{}, errors.New("invalid node name") + } + if baseURL == "" { + baseURL = old.BaseURL + } + if err := validateBaseURL(baseURL); err != nil { + return Node{}, err + } + enc := []byte(nil) + if strings.TrimSpace(token) != "" { + if len(token) < 24 { + return Node{}, errors.New("token must be at least 24 characters") + } + enc, err = m.encrypt([]byte(token)) + if err != nil { + return Node{}, err + } + } + en := old.Enabled + if enabled != nil { + en = *enabled + } + now := time.Now().Unix() + var res sql.Result + if enc != nil { + res, err = m.db.ExecContext(ctx, `UPDATE nodes SET name=?,base_url=?,token_enc=?,enabled=?,updated_at=? WHERE id=?`, name, baseURL, enc, en, now, id) + } else { + res, err = m.db.ExecContext(ctx, `UPDATE nodes SET name=?,base_url=?,enabled=?,updated_at=? WHERE id=?`, name, baseURL, en, now, id) + } + if err != nil { + return Node{}, err + } + n, _ := res.RowsAffected() + if n == 0 { + return Node{}, sql.ErrNoRows + } + return Node{ID: id, Name: name, BaseURL: baseURL, Enabled: en, CreatedAt: old.CreatedAt, UpdatedAt: now}, nil +} +func (m *Manager) Delete(ctx context.Context, id int64) error { + _, err := m.db.ExecContext(ctx, `DELETE FROM nodes WHERE id=?`, id) + return err +} +func (m *Manager) get(ctx context.Context, id int64) (storedNode, error) { + var n storedNode + var enc []byte + err := m.db.QueryRowContext(ctx, `SELECT id,name,base_url,token_enc,enabled,created_at,updated_at FROM nodes WHERE id=?`, id).Scan(&n.ID, &n.Name, &n.BaseURL, &enc, &n.Enabled, &n.CreatedAt, &n.UpdatedAt) + if err != nil { + return n, err + } + plain, err := m.decrypt(enc) + if err != nil { + return n, err + } + n.Token = string(plain) + return n, nil +} +func (m *Manager) Do(ctx context.Context, id int64, method, path string, body any) ([]byte, int, error) { + n, err := m.get(ctx, id) + if err != nil { + return nil, 0, err + } + if !n.Enabled { + return nil, 0, errors.New("node disabled") + } + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, n.BaseURL+path, rdr) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+n.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := m.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return nil, resp.StatusCode, err + } + if resp.StatusCode >= 300 { + return b, resp.StatusCode, fmt.Errorf("agent returned %s: %s", resp.Status, strings.TrimSpace(string(b))) + } + return b, resp.StatusCode, nil +} + +func (m *Manager) Stream(ctx context.Context, id int64, method, path string, body any, w http.ResponseWriter) error { + n, err := m.get(ctx, id) + if err != nil { + return err + } + if !n.Enabled { + return errors.New("node disabled") + } + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, n.BaseURL+path, rdr) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+n.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := *m.client + client.Timeout = 0 + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + for k, vs := range resp.Header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.WriteHeader(resp.StatusCode) + _, err = io.Copy(w, resp.Body) + return err +} + +func (m *Manager) encrypt(p []byte) ([]byte, error) { + b, err := aes.NewCipher(m.key) + if err != nil { + return nil, err + } + g, err := cipher.NewGCM(b) + if err != nil { + return nil, err + } + nonce := make([]byte, g.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return g.Seal(nonce, nonce, p, nil), nil +} +func (m *Manager) decrypt(v []byte) ([]byte, error) { + b, err := aes.NewCipher(m.key) + if err != nil { + return nil, err + } + g, err := cipher.NewGCM(b) + if err != nil { + return nil, err + } + if len(v) < g.NonceSize() { + return nil, errors.New("invalid encrypted token") + } + return g.Open(nil, v[:g.NonceSize()], v[g.NonceSize():], nil) +} + +// DialWebSocket opens an authenticated websocket to a remote agent. +func (m *Manager) DialWebSocket(ctx context.Context, id int64, path string) (*websocket.Conn, *http.Response, error) { + n, err := m.get(ctx, id) + if err != nil { + return nil, nil, err + } + if !n.Enabled { + return nil, nil, errors.New("node disabled") + } + wsURL, err := agentWebSocketURL(n.BaseURL, path) + if err != nil { + return nil, nil, err + } + h := http.Header{} + h.Set("Authorization", "Bearer "+n.Token) + d := websocket.Dialer{HandshakeTimeout: 15 * time.Second} + return d.DialContext(ctx, wsURL, h) +} + +func agentWebSocketURL(baseURL, path string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + rel, err := url.Parse(path) + if err != nil || !strings.HasPrefix(rel.Path, "/") { + return "", errors.New("invalid agent websocket path") + } + if u.Scheme == "https" { + u.Scheme = "wss" + } else if u.Scheme == "http" { + u.Scheme = "ws" + } else { + return "", errors.New("invalid agent websocket base URL") + } + u.Path = strings.TrimRight(u.Path, "/") + rel.Path + u.RawQuery = rel.RawQuery + u.Fragment = "" + return u.String(), nil +} diff --git a/internal/nodes/nodes_test.go b/internal/nodes/nodes_test.go new file mode 100644 index 0000000..ffd53d8 --- /dev/null +++ b/internal/nodes/nodes_test.go @@ -0,0 +1,31 @@ +package nodes + +import "testing" + +func TestAgentWebSocketURLPreservesQuery(t *testing.T) { + got, err := agentWebSocketURL("https://agent.example/base", "/agent/v1/stacks/demo/terminal?service=web&shell=sh") + if err != nil { + t.Fatal(err) + } + want := "wss://agent.example/base/agent/v1/stacks/demo/terminal?service=web&shell=sh" + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestValidateBaseURLRejectsQueryFragmentAndCredentials(t *testing.T) { + bad := []string{ + "https://user:pass@agent.example", + "https://agent.example?token=oops", + "https://agent.example/#frag", + "ftp://agent.example", + } + for _, u := range bad { + if err := validateBaseURL(u); err == nil { + t.Fatalf("expected %q to be rejected", u) + } + } + if err := validateBaseURL("https://agent.example/base"); err != nil { + t.Fatalf("valid URL rejected: %v", err) + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..4187452 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,533 @@ +package notify + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/tls" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/mail" + "net/smtp" + "net/url" + "strconv" + "strings" + "time" +) + +type Channel struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Config map[string]string `json:"config"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} +type Input struct { + Name string `json:"name"` + Type string `json:"type"` + Config map[string]string `json:"config"` + Enabled *bool `json:"enabled"` +} +type Message struct { + Title string + Body string + Status string + MonitorID int64 +} +type Service struct { + db *sql.DB + key []byte + client *http.Client +} + +func New(db *sql.DB, key []byte) *Service { + return &Service{db: db, key: key, client: &http.Client{Timeout: 15 * time.Second}} +} +func sanitize(c map[string]string) map[string]string { + out := map[string]string{} + for k, v := range c { + if isSecretKey(k) { + if v != "" { + out[k] = "••••••••" + } + } else { + out[k] = v + } + } + return out +} +func isSecretKey(k string) bool { + lk := strings.ToLower(k) + return strings.Contains(lk, "password") || strings.Contains(lk, "token") || strings.Contains(lk, "secret") +} +func (s *Service) encryptString(v string) (string, error) { + if v == "" || strings.HasPrefix(v, "enc:v1:") { + return v, nil + } + b, err := aes.NewCipher(s.key) + if err != nil { + return "", err + } + g, err := cipher.NewGCM(b) + if err != nil { + return "", err + } + nonce := make([]byte, g.NonceSize()) + if _, err = rand.Read(nonce); err != nil { + return "", err + } + out := g.Seal(nonce, nonce, []byte(v), nil) + return "enc:v1:" + base64.RawStdEncoding.EncodeToString(out), nil +} +func (s *Service) decryptString(v string) (string, error) { + if !strings.HasPrefix(v, "enc:v1:") { + return v, nil + } + raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(v, "enc:v1:")) + if err != nil { + return "", err + } + b, err := aes.NewCipher(s.key) + if err != nil { + return "", err + } + g, err := cipher.NewGCM(b) + if err != nil { + return "", err + } + if len(raw) < g.NonceSize() { + return "", errors.New("invalid encrypted notification config") + } + plain, err := g.Open(nil, raw[:g.NonceSize()], raw[g.NonceSize():], nil) + return string(plain), err +} +func (s *Service) encodeConfig(c map[string]string) (string, error) { + out := map[string]string{} + for k, v := range c { + if isSecretKey(k) { + e, err := s.encryptString(v) + if err != nil { + return "", err + } + out[k] = e + } else { + out[k] = v + } + } + b, err := json.Marshal(out) + return string(b), err +} +func (s *Service) decodeConfig(raw string) (map[string]string, error) { + out := map[string]string{} + if strings.TrimSpace(raw) == "" { + return out, nil + } + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, err + } + for k, v := range out { + if isSecretKey(k) { + d, err := s.decryptString(v) + if err != nil { + return nil, err + } + out[k] = d + } + } + return out, nil +} + +func normalize(in *Input) error { + in.Name = strings.TrimSpace(in.Name) + in.Type = strings.ToLower(strings.TrimSpace(in.Type)) + if in.Name == "" || len(in.Name) > 120 || strings.ContainsAny(in.Name, "\r\n") { + return errors.New("valid notification name required") + } + if in.Config == nil { + in.Config = map[string]string{} + } + endpoint := func(key string) error { + raw := strings.TrimSpace(in.Config[key]) + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return fmt.Errorf("%s must be an absolute http(s) URL without credentials", key) + } + in.Config[key] = strings.TrimRight(raw, "/") + return nil + } + switch in.Type { + case "webhook": + if err := endpoint("url"); err != nil { + return err + } + case "ntfy": + if err := endpoint("server"); err != nil { + return err + } + if topic := strings.TrimSpace(in.Config["topic"]); topic == "" || len(topic) > 200 || strings.ContainsAny(topic, "\r\n/?#") { + return errors.New("valid ntfy topic required") + } + case "gotify": + if err := endpoint("server"); err != nil { + return err + } + if strings.TrimSpace(in.Config["token"]) == "" { + return errors.New("gotify token required") + } + case "smtp": + host := strings.TrimSpace(in.Config["host"]) + if host == "" || strings.ContainsAny(host, "\r\n/") { + return errors.New("valid smtp host required") + } + security := strings.ToLower(strings.TrimSpace(in.Config["security"])) + if security == "" { + security = "starttls" + } + if security == "ssl" { + security = "tls" + } + if security != "none" && security != "starttls" && security != "tls" { + return errors.New("smtp security must be none, starttls or tls") + } + in.Config["security"] = security + port := strings.TrimSpace(in.Config["port"]) + if port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return errors.New("smtp port must be between 1 and 65535") + } + } + if _, err := mail.ParseAddress(strings.TrimSpace(in.Config["from"])); err != nil { + return errors.New("valid smtp from address required") + } + if _, err := mail.ParseAddressList(strings.TrimSpace(in.Config["to"])); err != nil { + return errors.New("valid smtp recipient list required") + } + if boolConfig(in.Config["auth"]) && strings.TrimSpace(in.Config["username"]) == "" { + return errors.New("smtp username required when authentication is enabled") + } + default: + return errors.New("type must be webhook, ntfy, gotify or smtp") + } + return nil +} +func (s *Service) List(ctx context.Context) ([]Channel, error) { + rows, e := s.db.QueryContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels ORDER BY name`) + if e != nil { + return nil, e + } + defer rows.Close() + out := []Channel{} + for rows.Next() { + var c Channel + var raw string + if e := rows.Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt); e != nil { + return nil, e + } + c.Config, e = s.decodeConfig(raw) + if e != nil { + return nil, e + } + c.Config = sanitize(c.Config) + out = append(out, c) + } + return out, rows.Err() +} +func (s *Service) Create(ctx context.Context, in Input) (Channel, error) { + if e := normalize(&in); e != nil { + return Channel{}, e + } + en := true + if in.Enabled != nil { + en = *in.Enabled + } + raw, e := s.encodeConfig(in.Config) + if e != nil { + return Channel{}, e + } + now := time.Now().Unix() + r, e := s.db.ExecContext(ctx, `INSERT INTO notification_channels(name,type,config_json,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?)`, in.Name, in.Type, raw, en, now, now) + if e != nil { + return Channel{}, e + } + id, _ := r.LastInsertId() + return Channel{ID: id, Name: in.Name, Type: in.Type, Config: sanitize(in.Config), Enabled: en, CreatedAt: now, UpdatedAt: now}, nil +} +func (s *Service) Update(ctx context.Context, id int64, in Input) (Channel, error) { + if e := normalize(&in); e != nil { + return Channel{}, e + } + old, e := s.get(ctx, id) + if e != nil { + return Channel{}, e + } + for k, v := range in.Config { + if v == "••••••••" { + in.Config[k] = old.Config[k] + } + } + en := true + if in.Enabled != nil { + en = *in.Enabled + } + raw, e := s.encodeConfig(in.Config) + if e != nil { + return Channel{}, e + } + now := time.Now().Unix() + _, e = s.db.ExecContext(ctx, `UPDATE notification_channels SET name=?,type=?,config_json=?,enabled=?,updated_at=? WHERE id=?`, in.Name, in.Type, raw, en, now, id) + if e != nil { + return Channel{}, e + } + return Channel{ID: id, Name: in.Name, Type: in.Type, Config: sanitize(in.Config), Enabled: en, CreatedAt: old.CreatedAt, UpdatedAt: now}, nil +} +func (s *Service) Delete(ctx context.Context, id int64) error { + _, e := s.db.ExecContext(ctx, `DELETE FROM notification_channels WHERE id=?`, id) + return e +} +func (s *Service) get(ctx context.Context, id int64) (Channel, error) { + var c Channel + var raw string + e := s.db.QueryRowContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels WHERE id=?`, id).Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt) + if e != nil { + return c, e + } + c.Config, e = s.decodeConfig(raw) + return c, e +} +func (s *Service) Test(ctx context.Context, id int64) error { + c, e := s.get(ctx, id) + if e != nil { + return e + } + return s.send(ctx, c, Message{Title: "Dockwatch test notification", Body: "Your notification provider is configured correctly.", Status: "test"}) +} +func (s *Service) Broadcast(ctx context.Context, m Message) { + rows, e := s.db.QueryContext(ctx, `SELECT id,name,type,config_json,enabled,created_at,updated_at FROM notification_channels WHERE enabled=1`) + if e != nil { + return + } + defer rows.Close() + for rows.Next() { + var c Channel + var raw string + if rows.Scan(&c.ID, &c.Name, &c.Type, &raw, &c.Enabled, &c.CreatedAt, &c.UpdatedAt) == nil { + c.Config, e = s.decodeConfig(raw) + if e != nil { + continue + } + go func(c Channel) { + x, k := context.WithTimeout(context.Background(), 20*time.Second) + defer k() + _ = s.send(x, c, m) + }(c) + } + } +} +func (s *Service) send(ctx context.Context, c Channel, m Message) error { + switch c.Type { + case "webhook": + return s.webhook(ctx, c, m) + case "ntfy": + return s.ntfy(ctx, c, m) + case "gotify": + return s.gotify(ctx, c, m) + case "smtp": + return s.smtp(ctx, c, m) + } + return errors.New("unsupported notification type") +} +func (s *Service) webhook(ctx context.Context, c Channel, m Message) error { + u := c.Config["url"] + if u == "" { + return errors.New("webhook url required") + } + b, _ := json.Marshal(map[string]any{"title": m.Title, "body": m.Body, "status": m.Status, "monitor_id": m.MonitorID, "timestamp": time.Now().Unix()}) + req, e := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(b)) + if e != nil { + return e + } + req.Header.Set("Content-Type", "application/json") + if t := c.Config["bearer_token"]; t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } + r, e := s.client.Do(req) + if e != nil { + return e + } + defer r.Body.Close() + if r.StatusCode >= 300 { + return fmt.Errorf("webhook: %s", r.Status) + } + return nil +} +func (s *Service) ntfy(ctx context.Context, c Channel, m Message) error { + u := strings.TrimRight(c.Config["server"], "/") + "/" + c.Config["topic"] + if c.Config["server"] == "" || c.Config["topic"] == "" { + return errors.New("ntfy server and topic required") + } + req, e := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(m.Body)) + if e != nil { + return e + } + req.Header.Set("Title", m.Title) + req.Header.Set("Tags", map[string]string{"down": "rotating_light", "up": "white_check_mark"}[m.Status]) + if t := c.Config["token"]; t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } + r, e := s.client.Do(req) + if e != nil { + return e + } + defer r.Body.Close() + if r.StatusCode >= 300 { + return fmt.Errorf("ntfy: %s", r.Status) + } + return nil +} +func (s *Service) gotify(ctx context.Context, c Channel, m Message) error { + server := strings.TrimRight(c.Config["server"], "/") + token := c.Config["token"] + if server == "" || token == "" { + return errors.New("gotify server and token required") + } + u := server + "/message?token=" + url.QueryEscape(token) + b, _ := json.Marshal(map[string]any{"title": m.Title, "message": m.Body, "priority": 5}) + req, e := http.NewRequestWithContext(ctx, "POST", u, bytes.NewReader(b)) + if e != nil { + return e + } + req.Header.Set("Content-Type", "application/json") + r, e := s.client.Do(req) + if e != nil { + return e + } + defer r.Body.Close() + if r.StatusCode >= 300 { + return fmt.Errorf("gotify: %s", r.Status) + } + return nil +} +func boolConfig(v string) bool { + v = strings.ToLower(strings.TrimSpace(v)) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +func (s *Service) smtp(ctx context.Context, c Channel, m Message) error { + host := strings.TrimSpace(c.Config["host"]) + port := strings.TrimSpace(c.Config["port"]) + security := strings.ToLower(strings.TrimSpace(c.Config["security"])) + if security == "" { + security = "starttls" + } + if security == "ssl" { + security = "tls" + } + if port == "" { + if security == "tls" { + port = "465" + } else { + port = "587" + } + } + fromRaw := strings.TrimSpace(c.Config["from"]) + toRaw := strings.TrimSpace(c.Config["to"]) + if host == "" || fromRaw == "" || toRaw == "" { + return errors.New("smtp host, from and to required") + } + fromAddr, err := mail.ParseAddress(fromRaw) + if err != nil { + return errors.New("invalid smtp from address") + } + toAddrs, err := mail.ParseAddressList(toRaw) + if err != nil || len(toAddrs) == 0 { + return errors.New("invalid smtp recipient list") + } + if security != "none" && security != "starttls" && security != "tls" { + return errors.New("smtp security must be none, starttls or tls") + } + authEnabled := boolConfig(c.Config["auth"]) + // Backward compatibility: existing configurations with a username implied auth. + if c.Config["auth"] == "" && strings.TrimSpace(c.Config["username"]) != "" { + authEnabled = true + } + if authEnabled && strings.TrimSpace(c.Config["username"]) == "" { + return errors.New("smtp username required when authentication is enabled") + } + addr := net.JoinHostPort(host, port) + dialer := &net.Dialer{Timeout: 15 * time.Second} + tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12, InsecureSkipVerify: boolConfig(c.Config["skip_verify"])} + var conn net.Conn + if security == "tls" { + conn, err = tls.DialWithDialer(dialer, "tcp", addr, tlsCfg) + } else { + conn, err = dialer.DialContext(ctx, "tcp", addr) + } + if err != nil { + return err + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } else { + _ = conn.SetDeadline(time.Now().Add(20 * time.Second)) + } + cl, err := smtp.NewClient(conn, host) + if err != nil { + return err + } + defer cl.Close() + if security == "starttls" { + ok, _ := cl.Extension("STARTTLS") + if !ok { + return errors.New("smtp server does not support STARTTLS") + } + if err = cl.StartTLS(tlsCfg); err != nil { + return err + } + } + if authEnabled { + if ok, _ := cl.Extension("AUTH"); !ok { + return errors.New("smtp server does not advertise AUTH") + } + auth := smtp.PlainAuth("", c.Config["username"], c.Config["password"], host) + if err = cl.Auth(auth); err != nil { + return err + } + } + if err = cl.Mail(fromAddr.Address); err != nil { + return err + } + tos := make([]string, 0, len(toAddrs)) + for _, a := range toAddrs { + tos = append(tos, a.String()) + if err = cl.Rcpt(a.Address); err != nil { + return err + } + } + w, err := cl.Data() + if err != nil { + return err + } + subject := strings.NewReplacer("\r", " ", "\n", " ").Replace(m.Title) + body := strings.ReplaceAll(strings.ReplaceAll(m.Body, "\r\n", "\n"), "\r", "\n") + body = strings.ReplaceAll(body, "\n", "\r\n") + msg := []byte("To: " + strings.Join(tos, ", ") + "\r\nFrom: " + fromAddr.String() + "\r\nSubject: " + subject + "\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n" + body + "\r\n") + if _, err = w.Write(msg); err != nil { + _ = w.Close() + return err + } + if err = w.Close(); err != nil { + return err + } + return cl.Quit() +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..b816c14 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,24 @@ +package notify + +import ( + "strings" + "testing" +) + +func TestConfigSecretsEncryptedAtRest(t *testing.T) { + s := New(nil, []byte("0123456789abcdef0123456789abcdef")) + raw, err := s.encodeConfig(map[string]string{"url": "https://example.invalid/hook", "token": "super-secret-token", "password": "pw123"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(raw, "super-secret-token") || strings.Contains(raw, "pw123") { + t.Fatalf("secret leaked in persisted config: %s", raw) + } + cfg, err := s.decodeConfig(raw) + if err != nil { + t.Fatal(err) + } + if cfg["token"] != "super-secret-token" || cfg["password"] != "pw123" { + t.Fatalf("roundtrip mismatch: %#v", cfg) + } +} diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..d893818 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,47 @@ +param( + [switch]$NoEnv +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$ProjectRoot = $PSScriptRoot +Set-Location $ProjectRoot + +function Import-DotEnv { + param([Parameter(Mandatory = $true)][string]$Path) + + Get-Content -LiteralPath $Path | ForEach-Object { + $line = $_.Trim() + if (-not $line -or $line.StartsWith("#")) { return } + + $parts = $line.Split("=", 2) + if ($parts.Count -ne 2) { return } + + $name = $parts[0].Trim() + $value = $parts[1].Trim() + if (-not $name) { return } + + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } +} + +if (-not $NoEnv) { + $envFile = Join-Path $ProjectRoot ".env" + if (Test-Path -LiteralPath $envFile) { + Import-DotEnv -Path $envFile + } + else { + Write-Warning ".env nicht gefunden. Es werden vorhandene Umgebungsvariablen und Programm-Defaults verwendet." + } +} + + + +go run ./cmd/dockwatch +exit $LASTEXITCODE diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ae2af98 --- /dev/null +++ b/web/app.js @@ -0,0 +1,173 @@ +const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; +const asArray=v=>Array.isArray(v)?v:[]; +const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const state={me:{role:'viewer'},system:null,nodes:[],nodeHealth:{},stacks:[],monitors:[],services:[],node:0,view:'dashboard',stack:null,monitor:null,checks:[],dirty:false,draftKey:'',logStream:null,logBuffer:'',logPaused:false,logAutoScroll:true,refreshing:false}; +async function api(path,opt={}){let r;try{r=await fetch(path,{headers:{'Content-Type':'application/json',...(opt.headers||{})},...opt})}catch(e){if(e?.name!=='AbortError')setApiState(false,e.message);throw e}setApiState(true);if(r.status===401){location='/auth/login';throw Error('Nicht angemeldet')}const txt=await r.text();if(!r.ok)throw Error(txt||r.statusText);if(!txt)return null;try{return JSON.parse(txt)}catch{throw Error('Ungültige JSON-Antwort vom Server')}} +function setApiState(ok,msg=''){const e=$('#apiState');if(!e)return;e.classList.toggle('offline',!ok);e.title=ok?'API erreichbar':('API nicht erreichbar'+(msg?': '+msg:''));const t=e.querySelector('span');if(t)t.textContent=ok?'API':'Offline'} +function applyPreferences(){const theme=localStorage.getItem('dockwatch:theme')||'dark';document.documentElement.dataset.theme=theme;document.body.classList.toggle('sidebar-collapsed',localStorage.getItem('dockwatch:sidebar')==='collapsed'&&innerWidth>860);const t=$('#themeToggle');if(t)t.textContent=theme==='light'?'☾':'☀'} +function toggleTheme(){const next=(document.documentElement.dataset.theme||'dark')==='dark'?'light':'dark';localStorage.setItem('dockwatch:theme',next);applyPreferences()} +function teardownInteractive(){stopLiveLogs();closeTerminal()} +function setBusy(btn,on,label=''){if(!btn)return;btn.disabled=!!on;btn.classList.toggle('busy',!!on);if(on){if(!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;if(label)btn.textContent=label}else if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}} +function toast(msg){const e=$('#toast');e.textContent=msg;e.style.display='block';clearTimeout(e._t);e._t=setTimeout(()=>e.style.display='none',4200)} +function roleOK(min='operator'){return state.me.role==='admin'||(min==='operator'&&state.me.role==='operator')} +function qnode(){return state.node?`?node_id=${state.node}`:''} +function joinQ(base,extra){return base+(base.includes('?')?'&':'?')+extra} +function badge(s){return `${esc(s||'unknown')}`} +function setDirty(v){state.dirty=v;$('#dirtyTop').hidden=!v;window.onbeforeunload=v?()=>true:null} +function draftKey(name='new'){return `dockwatch:draft:${state.node}:${name||'new'}`} +function saveDraft(){if(!state.dirty)return;const c=$('#composeText'),e=$('#envText'),n=$('#stackName');if(!c||!n)return;const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value,content:r.querySelector('.secContent').value}));const env_files=collectManaged('.envfilerow');const configs=collectManaged('.configrow');localStorage.setItem(draftKey(n.value||'new'),JSON.stringify({name:n.value,compose:c.value,env:e?.value||'',secrets,env_files,configs,ts:Date.now()}))} +setInterval(saveDraft,2500); +function clearDraft(name){localStorage.removeItem(draftKey(name));localStorage.removeItem(draftKey('new'))} +function fmtTime(ts){if(!ts)return'—';return new Date(ts*1000).toLocaleString()} +function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/1000-ts));if(s<60)return`${s}s`;if(s<3600)return`${Math.floor(s/60)}m`;if(s<86400)return`${Math.floor(s/3600)}h`;return`${Math.floor(s/86400)}d`} +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 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 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(', ')||'—')}
`} +function monitorTable(items){if(!items.length)return'
No monitors configured.
';return `${items.map(m=>``).join('')}
NameStatusTypeUptime 24h
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}${esc(m.type.toUpperCase())}${Number(m.uptime_24h||0).toFixed(2)}%
`} +function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}
${renderStackList(state.stacks)}
${state.stack?stackDetailHTML(state.stack):'
▱
Select a stack or create a new one.
'}
`;$('#newStack')?.addEventListener('click',newStack);$('#stackRefresh').onclick=async()=>{if(state.dirty)return toast('Editor contains unsaved changes.');await refreshData(true);renderStacks()};$('#stackSearch').oninput=e=>$('#stackList').innerHTML=renderStackList(state.stacks.filter(s=>s.name.toLowerCase().includes(e.target.value.toLowerCase())));wireStackList();if(state.stack)wireStackDetail()} +function renderStackList(items){return items.map(s=>`
${esc(s.name)}${badge(s.status)}
${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}
`).join('')||'
No stacks
'} +function wireStackList(){$$('[data-stack]').forEach(x=>x.onclick=()=>openStack(x.dataset.stack))} +async function openStack(name){if(!roleOK())return toast('Stack configuration and logs require Operator access.');if(state.dirty&&state.stack?.name!==name&&!confirm('Ungespeicherte Änderungen verwerfen?'))return;if(state.stack?.name!==name)teardownInteractive();try{state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);setDirty(false);state.view='stacks';renderStacks()}catch(e){toast(e.message)}} +function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))return;teardownInteractive();const draft=localStorage.getItem(draftKey('new'));let st={name:'',status:'new',services:[],compose:`services: + app: + image: nginx:alpine + restart: unless-stopped +`,env:'',secrets:[],env_files:[],configs:[]};if(draft){try{const d=JSON.parse(draft);if(confirm('Gespeicherten lokalen Stack-Entwurf wiederherstellen?'))st={...st,...d}}catch{}}state.stack=st;setDirty(false);renderStacks()} +function stackDetailHTML(st){const sv=st.services||[];return `
▱

${esc(st.name||'New compose stack')}

${st.name?esc(nodeName()):'Draft · not deployed'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} +function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
A local unsaved draft exists for this stack.
':''}
Full Compose Designer parsing…
compose.yaml Source of truth
Visual editor all fields · AST patch mode
Parsing Compose…
Every present Compose value is editable as string, number, boolean, null, map or array.Current spec fields are suggested; x-* and future fields remain editable too.Invalid YAML pauses visual sync without replacing your source.
`} + +const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir']; +const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version']; +const COMPOSE_FIELD_HINTS={image:'Container image',build:'Build configuration',command:'Override image command',entrypoint:'Override image entrypoint',environment:'Environment variables',env_file:'Environment files',ports:'Published ports',expose:'Exposed container ports',volumes:'Mounts and named volumes',networks:'Network attachments',depends_on:'Service dependencies',healthcheck:'Container health check',deploy:'Deployment constraints and resources',develop:'Compose watch/development settings',secrets:'Granted secrets',configs:'Granted configs',logging:'Logging driver/options',restart:'Container restart policy',pull_policy:'Image pull policy',provider:'External provider configuration',post_start:'Post-start lifecycle hooks',pre_start:'Pre-start lifecycle hooks',pre_stop:'Pre-stop lifecycle hooks',models:'AI model attachments'}; +const COMPOSE_CHILD_FIELDS={build:['context','dockerfile','dockerfile_inline','entitlements','args','ssh','labels','cache_from','cache_to','no_cache','no_cache_filter','additional_contexts','network','provenance','sbom','pull','target','shm_size','extra_hosts','isolation','privileged','secrets','tags','ulimits','platforms'],healthcheck:['test','interval','timeout','retries','start_period','start_interval','disable'],logging:['driver','options'],deploy:['mode','endpoint_mode','replicas','labels','rollback_config','update_config','resources','restart_policy','placement'],resources:['limits','reservations'],limits:['cpus','memory','pids'],reservations:['cpus','memory','generic_resources','devices'],restart_policy:['condition','delay','max_attempts','window'],update_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],rollback_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],placement:['constraints','preferences','max_replicas_per_node'],develop:['watch'],watch:['path','action','target','ignore','exec','initial_sync'],credential_spec:['config','file','registry'],extends:['file','service'],provider:['type','options'],ports:['name','mode','host_ip','target','published','protocol','app_protocol'],volumes:['type','source','target','read_only','consistency','bind','volume','tmpfs','image'],bind:['propagation','create_host_path','selinux','recursive'],volume:['nocopy','subpath'],tmpfs:['size','mode'],image:['subpath'],depends_on:['restart','required','condition'],networks:['aliases','interface_name','ipv4_address','ipv6_address','link_local_ips','mac_address','driver_opts','priority','gw_priority'],secrets:['source','target','uid','gid','mode'],configs:['source','target','uid','gid','mode'],ipam:['driver','config','options'],network:['attachable','driver','driver_opts','enable_ipv4','enable_ipv6','external','ipam','internal','labels','name'],config:['file','environment','content','external','name'],secret:['file','environment','external','name'],model:['model','context_size','runtime_flags'],blkio_config:['device_read_bps','device_read_iops','device_write_bps','device_write_iops','weight','weight_device'],ulimits:['soft','hard'],post_start:['command','user','privileged','working_dir','environment'],pre_start:['command','user','privileged','working_dir','environment'],pre_stop:['command','user','privileged','working_dir','environment']}; +function composeSuggestions(path,opt={}){if(opt.serviceRoot)return COMPOSE_SERVICE_FIELDS;const clean=path.filter(x=>!/^\d+$/.test(String(x)));const last=clean[clean.length-1]||'';if(COMPOSE_CHILD_FIELDS[last])return COMPOSE_CHILD_FIELDS[last];if(clean.length===1&&clean[0]==='networks')return COMPOSE_CHILD_FIELDS.network;if(clean.length===1&&clean[0]==='volumes')return ['driver','driver_opts','external','labels','name'];if(clean.length===1&&clean[0]==='configs')return COMPOSE_CHILD_FIELDS.config;if(clean.length===1&&clean[0]==='secrets')return COMPOSE_CHILD_FIELDS.secret;if(clean.length===1&&clean[0]==='models')return COMPOSE_CHILD_FIELDS.model;return []} + +let composeVisualModel=null,composeParseSeq=0,composePatchBusy=false,composePendingPatch=null,composeSection='services'; +function pathKey(path){return path.map(String).join('\u001f')} +function pathAttr(path){return encodeURIComponent(JSON.stringify(path))} +function pathFromAttr(v){try{return JSON.parse(decodeURIComponent(v))}catch{return[]}} +function typeOfValue(v){if(v===null)return'null';if(Array.isArray(v))return'array';if(typeof v==='object')return'map';return typeof v} +function cloneJSON(v){return v===undefined?undefined:JSON.parse(JSON.stringify(v))} +function composeSetLocal(path,val,del=false){if(!composeVisualModel)return;let cur=composeVisualModel;for(let i=0;iVisual editor paused

${esc(e.message)}

Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.

`}} +function renderComposeSections(){const host=$('#composeSections');if(!host)return;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes','Volumes'],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})} +function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})} +function sectionPath(){return composeSection==='project'?[]:[composeSection]} +function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(['networks','volumes','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'
Nothing configured in this section.
';wireComposeTree()} +function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const rows=Object.entries(v).map(([k,val])=>`
${esc(k)} ${esc(label)}
${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}
`).join('');return `${rows}
`} +function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `
${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}
${suggestions.length?`${suggestions.map(x=>``).join('')}`:''}
`} +function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
${esc(k)}${hint?`${esc(hint)}`:''}${k.startsWith('x-')?'extension':''}
${t}
${composeValueEditor(val,path,k,{serviceRoot:false})}
`} +function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `
${v.map((x,i)=>`
#${i+1}
${composeValueEditor(x,[...path,String(i)],label)}
`).join('')}
`;if(t==='boolean')return `
${typeSwitcher(path,t)}
`;if(t==='null')return `
null${typeSwitcher(path,t)}
`;if(t==='number')return `
${typeSwitcher(path,t)}
`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `
${multiline?``:``}${typeSwitcher(path,'string')}
`} +function typeSwitcher(path,t){return ``} +function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''} +function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v)};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})} +function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}} +async function queueComposePatch(path,value,del=false){composePendingPatch={path,value,delete:del};if(composePatchBusy)return;while(composePendingPatch){const p=composePendingPatch;composePendingPatch=null;composePatchBusy=true;try{const ta=$('#composeText');if(!ta)break;const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();await parseComposeVisual()}catch(e){toast('Compose patch failed: '+e.message);await parseComposeVisual()}finally{composePatchBusy=false}}} +function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})} +function envTab(st){return `
`} +function secretsTab(st){return `
Secret values are written with mode 0600. Use Compose secrets: with file: ./secrets/name. Existing values are visible only to operators who can edit the stack.
${(st.secrets||[]).map(secretRow).join('')}
`} +function secretRow(s={}){return `
`} +function managedFilesTab(kind,files,title,help){return `
${title} · ${help} Managed files are validated together with the stack.
${files.map(f=>managedFileRow(kind,f)).join('')}
`} +function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `
`} +function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)} +function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${esc(v.image||'—')}Ports${esc(v.ports||'—')}Command${esc(v.command||'—')}
`).join('')}
`} +function logsTab(){return `
idle
Select “Load” or “Follow”.
`} +function graphTab(){return `
Load the normalized Compose dependency graph.
`} +function updatesTab(){return `
Compare installed image digests with registry manifests.
`} +function consoleTab(sv){return `
Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.
Terminal disconnected. +
`} +function dangerTab(st){return st.name?`
Safe delete is the default. It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.
`:'
Save the stack first.
'} +function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})} +function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} +function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} +function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Draft could not be restored.')}} +async function saveStack(){const name=$('#stackName')?.value.trim();if(!name)return toast('Stack name is required.');const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value.trim(),content:r.querySelector('.secContent').value})).filter(s=>s.name);const env_files=collectManaged('.envfilerow'),configs=collectManaged('.configrow');try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`,{method:'PUT',body:JSON.stringify({compose:$('#composeText').value,env:$('#envText')?.value||state.stack.env||'',secrets,env_files,configs})});clearDraft(name);setDirty(false);toast('Stack saved and Compose validated.');state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);await refreshData(false);renderStacks()}catch(e){toast(e.message)}} +async function stackAction(action){if(!state.stack?.name)return;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/actions/${action}${qnode()}`,{method:'POST'});const out=$('#actionOut');out.style.display='block';out.textContent=r.output||'OK';toast(`${action} completed`);await refreshData(false);state.stack=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}${qnode()}`);renderStacks()}catch(e){toast(e.message)}} +async function deleteStack(purge=false){const name=state.stack?.name;if(!name)return;if(purge){const typed=prompt(`FULL PURGE deletes the entire stack folder, including unrelated files or bind-mount data.\n\nType ${name} to continue:`);if(typed!==name)return}else if(!confirm(`Delete the Dockwatch-managed definition for ${name}? Containers are not automatically removed and unrelated files are preserved.`))return;try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}${state.node?'&':'?'}purge=${purge?'true':'false'}`,{method:'DELETE'});state.stack=null;setDirty(false);clearDraft(name);await refreshData(true);renderStacks();toast(purge?'Stack folder purged.':'Stack definition deleted safely.')}catch(e){toast(e.message)}} +async function loadLogs(){try{const r=await api(joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=500'));state.logBuffer=r.output||'';state.logPaused=false;renderLogBuffer();setLogState('loaded')}catch(e){toast(e.message)}} +function startLiveLogs(){stopLiveLogs();const url=joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=200&live=true');state.logBuffer='';state.logPaused=false;state.logStream=new EventSource(url);setLogState('live');state.logStream.onmessage=e=>{let line;try{line=JSON.parse(e.data)}catch{line=e.data}state.logBuffer+=(line+'\n');if(state.logBuffer.length>1048576)state.logBuffer=state.logBuffer.slice(-1048576);if(!state.logPaused)renderLogBuffer()};state.logStream.onerror=()=>setLogState('reconnecting')} +function stopLiveLogs(){state.logStream?.close();state.logStream=null;setLogState('stopped')} +function filterLogs(){renderLogBuffer()} +function renderLogBuffer(){const b=$('#logBox');if(!b)return;const q=$('#logFilter')?.value.toLowerCase()||'',raw=state.logBuffer||'';b.textContent=q?raw.split('\n').filter(l=>l.toLowerCase().includes(q)).join('\n'):raw;if(state.logAutoScroll)b.scrollTop=b.scrollHeight} +function setLogState(v){const e=$('#logState');if(!e)return;e.textContent=v;e.className='logstate '+(v==='live'?'live':v==='reconnecting'?'reconnecting':v==='paused'?'paused':'')} +function togglePauseLogs(){state.logPaused=!state.logPaused;const b=$('#pauseLogs');if(b)b.textContent=state.logPaused?'▶ Resume':'Ⅱ Pause';setLogState(state.logPaused?'paused':(state.logStream?'live':'loaded'));if(!state.logPaused)renderLogBuffer()} +function downloadLogs(){const blob=new Blob([state.logBuffer||''],{type:'text/plain;charset=utf-8'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${state.stack?.name||'dockwatch'}-${new Date().toISOString().replace(/[:.]/g,'-')}.log`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)} +async function runExec(){const service=$('#execService').value,command=$('#execCmd').value;if(!service||!command)return;$('#execOut').textContent=`$ ${command}\n`;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/exec${qnode()}`,{method:'POST',body:JSON.stringify({service,command})});$('#execOut').textContent+=r.output||''}catch(e){$('#execOut').textContent+=`ERROR: ${e.message}`}} + +function renderMonitors(){setCrumb('Observability / Probes');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}
${monitorListHTML(state.monitors)}
${state.monitor?monitorDetailHTML(state.monitor,state.checks):'
♡
Select a monitor to inspect uptime and latency.
'}
`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()} +function monitorListHTML(ms){return ms.map(m=>`
${esc(m.name)}${badge(m.status)}
${esc(m.type.toUpperCase())} · ${esc(m.target)}
${heartbeatBars([],m.status,24)}
`).join('')||'
No monitors
'} +function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))} +async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}} +function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>``).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'').join('')+a.map(c=>``).join('')} +function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'
No heartbeat data yet.
';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return ``} +function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `
♡

${esc(m.name)}

${esc(m.target)}
${badge(m.status)}
${roleOK()?`${m.status==='paused'?'':''}${m.status==='maintenance'?'':''}`:''}
24 hour uptime
${Number(m.uptime_24h||0).toFixed(3)}%
${badge(m.status)}
${heartbeatBars(c,m.status,64)}
Last heartbeat${fmtAgo(m.last_checked_at)}
Last latency${m.last_latency_ms||0} ms
Average latency${avg} ms
Recent success${ratio.toFixed(1)}%
Interval${m.interval_seconds}s
${m.last_message?`
Last result: ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}
`:''}

Response time

last ${c.length} heartbeats
${latencyChart(c)}
${m.status==='maintenance'?`
Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.
`:''}

Configuration

Type${esc(m.type.toUpperCase())}Method${esc(m.method||'GET')}
Expected status${m.expected_min}–${m.expected_max}Timeout${m.timeout_ms} ms
Keyword${esc(m.keyword||'—')}Probe environment${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}
${roleOK()?'
':''}
`} +function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)} +async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Checking');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Check OK · ${c.latency_ms} ms`:`Check failed · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}} +function monitorModal(m=null){const isEdit=!!m;modal(`

${isEdit?'Edit monitor':'New monitor'}

`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://example.com':t==='tcp'?'host:port':t==='dns'?'example.com':'container-name-or-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>``).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id)} +async function saveMonitor(id){const enabled=state.monitor?.id===id?state.monitor.enabled:true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor updated.':'Monitor created.')}catch(e){toast(e.message)}} +async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor paused.':'Monitor resumed.')}catch(e){toast(e.message)}} +function maintenanceModal(m){modal(`

Maintenance · ${esc(m.name)}

Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.
`);$('#startMaint').onclick=()=>startMaintenance(m.id)} +async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Maintenance started.')}catch(e){toast(e.message)}} +async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Maintenance ended.')} +async function deleteMonitor(){if(!confirm(`Delete monitor ${state.monitor.name} and its heartbeat history?`))return;await api(`/api/monitors/${state.monitor.id}`,{method:'DELETE'});state.monitor=null;state.checks=[];await refreshData(true);renderMonitors()} + +async function renderServices(){setCrumb('Observability / Services');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}
${state.services.length?`${state.services.map(g=>``).join('')}
ServiceStatusProbes
${esc(g.name)}
${esc(g.description||'')}
${badge(g.status)}${(g.monitors||[]).length}
${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}
${roleOK()?` `:''}
`:'
No services configured. Create a service and assign probes to it.
'}
`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete service? Probes become ungrouped.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})} +function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`

${x?'Edit':'New'} service

${state.monitors.map(m=>``).join('')||'No probes configured.'}
A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.
`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}} +async function renderStatusPages(){setCrumb('Observability / Status Pages');$('#content').innerHTML=`${pageHead('Public status pages','Publish only selected services and their aggregate status.',state.me.role==='admin'?'':'')}
Loading status pages…
`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
NamePublic URLServicesEnabled
${esc(x.name)}
${esc(x.description||'')}
/status/${esc(x.slug)}${(x.service_ids||[]).length}${x.enabled?'Public':'Disabled'}${state.me.role==='admin'?` `:''}
`:'
No public status pages configured.
';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete status page?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`
${esc(e.message)}
`}} +function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`

${x?'Edit':'New'} public status page

${state.services.map(g=>``).join('')||'Create services first.'}
`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}} + +function renderMaintenance(){setCrumb('Observability / Maintenance');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Maintenance','Suppress monitoring alerts during planned work.')}

Active maintenance windows

${ms.length?`${ms.map(m=>``).join('')}
MonitorTargetUntilNote
${esc(m.name)}${esc(m.target)}${m.maintenance_until?fmtTime(m.maintenance_until):'Manual end'}${esc(m.maintenance_note||'—')}
`:'
No active maintenance windows.
'}

Start maintenance

${state.monitors.filter(m=>m.status!=='maintenance').map(m=>``).join('')}
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}
`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))} +function renderNodes(){setCrumb('System / Environments');const local=`
◎
Local Docker
Docker socket
${badge('up')}
${esc(state.system?.build?.version||'local')}
Local—`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'':'')}
${local}${state.nodes.map(n=>``).join('')}
NameStatusConnectionActions
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Checking…':badge('paused')}${n.enabled?'Bearer agent':'Disabled'}${state.me.role==='admin'?` `:''}
`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('Remove this environment?')){await api('/api/nodes/'+b.dataset.delnode,{method:'DELETE'});const x=await api('/api/nodes');state.nodes=Array.isArray(x)?x:[];renderNodePicker();renderNodes()}});loadNodeHealth()} +function nodeModal(x=null){modal(`

${x?'Edit':'Add'} remote agent

${x?``:''}
`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}} +async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}
v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}
`}catch(e){el.innerHTML=`${badge('down')}
unreachable
`}}))} +function resourceTitle(k){return({containers:'Containers',images:'Images',volumes:'Volumes',networks:'Networks'})[k]||k} +function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)} +function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''} +async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
${esc(e.message)}
`}} +function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='
No matching items found.
';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${i===0?`
⬡${esc(r[c[0]]||'—')}
`:esc(r[c[0]]||'—')}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} +function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} +function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]))} +async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}} + +function registryLoginModal(){modal(`

Registry login

Credentials are written by Docker CLI to the persistent Docker config on this environment. The password is passed through stdin, not a command-line argument.
`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}} +function registryLogoutModal(){modal(`

Registry logout

`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}} + +function resourceCreateModal(kind){if(kind==='images'){modal(`

Pull image

`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image reference required.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`

Create ${isNet?'network':'volume'}

${isNet?'':''}
`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name required.');closeModal();await dockerResourceAction(kind,'create',body)}} +async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`

${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}

${kind==='containers'?`
CPU${esc(st.CPUPerc||'—')}
Memory${esc(st.MemUsage||'—')}
Network I/O${esc(st.NetIO||'—')}
Block I/O${esc(st.BlockIO||'—')}
`:''}
${esc(JSON.stringify(i,null,2))}
`)}catch(e){toast(e.message)}} +function showOutput(title,text){modal(`

${esc(title)}

${esc(text||'OK')}
`)} + +let terminalWS=null; +function wsURL(path){const proto=location.protocol==='https:'?'wss:':'ws:';return `${proto}//${location.host}${path}`} +function stripANSI(s){return String(s||'').replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g,'').replace(/\r/g,'')} +function closeTerminal(){const had=!!terminalWS;if(terminalWS){terminalWS.close();terminalWS=null}const out=$('#execOut');if(had&&out)out.textContent+='\n[disconnected]\n'} +function terminalKey(e){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;let d='';if(e.ctrlKey&&e.key.length===1){const c=e.key.toUpperCase().charCodeAt(0);if(c>=64&&c<=95)d=String.fromCharCode(c-64)}else{const map={Enter:'\r',Backspace:'\x7f',Tab:'\t',Escape:'\x1b',ArrowUp:'\x1b[A',ArrowDown:'\x1b[B',ArrowRight:'\x1b[C',ArrowLeft:'\x1b[D',Home:'\x1b[H',End:'\x1b[F',Delete:'\x1b[3~',PageUp:'\x1b[5~',PageDown:'\x1b[6~'};d=map[e.key]||(e.key.length===1&&!e.metaKey&&!e.altKey?e.key:'')}if(d){e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:d}))}} +function terminalResize(){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;const el=$('#execOut');if(!el)return;const cols=Math.max(40,Math.floor(el.clientWidth/7.2)),rows=Math.max(12,Math.floor(el.clientHeight/17));terminalWS.send(JSON.stringify({type:'resize',cols,rows}))} +function openTerminal(){closeTerminal();if(!state.stack?.name)return toast('Save the stack first.');const service=$('#execService')?.value;if(!service)return toast('No service selected.');const shell=$('#execShell')?.value||'sh';const q=new URLSearchParams({service,shell});if(state.node)q.set('node_id',String(state.node));const out=$('#execOut');out.textContent=`Connecting to ${service}...\n`;terminalWS=new WebSocket(wsURL(`/api/stacks/${encodeURIComponent(state.stack.name)}/terminal?${q}`));terminalWS.onopen=()=>{out.textContent='';out.focus();terminalResize()};terminalWS.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='output'){out.textContent+=stripANSI(m.data);out.scrollTop=out.scrollHeight}else if(m.type==='error'){out.textContent+=`\n[error] ${m.data}\n`}else if(m.type==='exit'){out.textContent+='\n[session ended]\n'}}catch{out.textContent+=stripANSI(e.data)}};terminalWS.onerror=()=>toast('Terminal websocket failed.');terminalWS.onclose=()=>{terminalWS=null};out.onkeydown=terminalKey;out.onpaste=e=>{if(!terminalWS)return;e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:e.clipboardData.getData('text')}))};} +async function loadGraph(){if(!state.stack?.name)return;const box=$('#graphBox');box.innerHTML='
Resolving Compose model…
';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`
${esc(e.message)}
`}} +function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='
No graph nodes.
';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `${esc(e.kind)}`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `${esc(n.label)}${esc(n.image||n.kind)}`}).join('');box.innerHTML=`${lines}${ns}`} +async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='
Checking registry manifests…
';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`${rows.map(r=>``).join('')}
ServiceImageLocal digestRemote digestStatus
${esc(r.service)}${esc(r.image)}${esc(r.local_digest||'—')}${esc(r.remote_digest||'—')}${r.error?`check failed`:r.update?'Update available':'Current'}
`}catch(e){box.innerHTML=`
${esc(e.message)}
`}} + +async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','')}
Loading audit events…
`;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
TimeActorActionResourceStatusDetails
${fmtTime(x.created_at)}${esc(x.actor)}${esc(x.action)}${esc(x.resource||'—')}${x.status>=200&&x.status<300?''+x.status+'':''+x.status+''}${esc(JSON.stringify(x.detail||{}))}
`:'
No matching audit events.
'}catch(e){$('#activityPanel').innerHTML=`
${esc(e.message)}
`}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()} + +async function renderGit(){setCrumb('Docker / Git Stacks');$('#content').innerHTML=`${pageHead('Git stacks','Synchronize Compose stacks from Git and deploy them from signed webhooks.',roleOK()?'':'')}
Loading Git sources…
`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
StackEnvironmentRepositoryBranchCommitLast syncAuto deploy
${esc(x.stack_name)}${x.last_error?`
${esc(x.last_error)}
`:''}
${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}${esc(x.repo_url)}${esc(x.branch)}${esc((x.last_commit||'—').slice(0,12))}${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}${x.auto_deploy?'Yes':'No'}${roleOK()?` `:''}
`:'
No Git sources configured.
';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete Git source configuration?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`
${esc(e.message)}
`}} +function gitModal(x=null){modal(`

${x?'Edit':'Add'} Git source

`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}} +function showGitSecret(r){modal(`

Webhook created

Copy this secret now. It is stored encrypted and will not be shown again.

GitHub: use the secret for X-Hub-Signature-256. GitLab: send it as X-Gitlab-Token. Generic hooks may use X-Webhook-Token.

`)} +async function gitSync(id){try{toast('Git sync started…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git stack synchronized.')}catch(e){toast(e.message);renderGit()}} + +async function renderNotifications(){setCrumb('Observability / Notifications');$('#content').innerHTML=`${pageHead('Notifications','Send monitor state transitions to Webhook, ntfy, Gotify or SMTP.',state.me.role==='admin'?'':'')}
Loading providers…
`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
NameProviderEnabledConfiguration
${esc(x.name)}${esc(x.type)}${x.enabled?'Enabled':'Disabled'}${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}${state.me.role==='admin'?` `:''}
`:'
No notification providers configured.
';$$('[data-ntest]').forEach(b=>b.onclick=async()=>{try{await api(`/api/notifications/${b.dataset.ntest}/test`,{method:'POST'});toast('Test notification sent.')}catch(e){toast(e.message)}});$$('[data-nedit]').forEach(b=>b.onclick=()=>notificationModal(rows.find(x=>x.id===Number(b.dataset.nedit))));$$('[data-ndel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete notification provider?')){await api('/api/notifications/'+b.dataset.ndel,{method:'DELETE'});renderNotifications()}})}catch(e){$('#notifyPanel').innerHTML=`
${esc(e.message)}
`}} +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)}}} + +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=''} +init().catch(e=>toast(e.message)); diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..a52a994 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,6 @@ +package web + +import "embed" + +//go:embed index.html app.js styles.css +var FS embed.FS diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5ddb5cc --- /dev/null +++ b/web/index.html @@ -0,0 +1,42 @@ + + + + + + Dockwatch + + + +
+ +
+
Dashboard
API
+
+
+
+
+ + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..afb3a7e --- /dev/null +++ b/web/styles.css @@ -0,0 +1,20 @@ +:root{--bg:#0d1017;--side:#0a0d13;--panel:#121722;--panel2:#171d29;--panel3:#1c2330;--line:#252d3b;--line2:#303a4a;--text:#e9edf5;--muted:#8994a6;--green:#34d399;--green2:#183c34;--red:#fb7185;--red2:#44212a;--amber:#fbbf24;--amber2:#463818;--blue:#60a5fa;--purple:#a78bfa;--shadow:0 16px 44px #0005;--radius:9px}*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--text);font:13px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;height:100%}button,input,select,textarea{font:inherit}button{cursor:pointer}#shell{min-height:100%;display:grid;grid-template-columns:218px 1fr}.sidebar{background:var(--side);border-right:1px solid var(--line);position:fixed;left:0;top:0;bottom:0;width:218px;display:flex;flex-direction:column;z-index:10}.brand{height:64px;padding:13px 16px;display:flex;gap:11px;align-items:center;border-bottom:1px solid var(--line)}.brandmark{width:34px;height:34px;border-radius:8px;display:grid;place-items:center;background:linear-gradient(145deg,#6d5dfc,#8b5cf6);font-size:11px;font-weight:900;box-shadow:0 0 24px #7c3aed40}.brand strong{display:block;font-size:15px;letter-spacing:.1px}.brand small,.usertext small{color:var(--muted);display:block;font-size:11px}.environment{margin:11px 10px 5px;padding:8px 9px;background:var(--panel);border:1px solid var(--line);border-radius:7px;display:flex;gap:8px;align-items:center}.environment .pulse{width:7px;height:7px;background:var(--green);border-radius:99px;box-shadow:0 0 0 4px #34d39918}.environment>div{min-width:0;flex:1}.environment small{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em}.environment select{padding:0;margin:0;border:0;background:transparent;color:var(--text);width:100%;outline:0;font-size:12px}nav{padding:4px 8px;overflow:auto;flex:1}.navlabel{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:#556174;padding:15px 9px 5px}nav button{width:100%;display:flex;align-items:center;gap:10px;border:0;background:transparent;color:#a9b1c0;border-radius:6px;padding:7px 9px;text-align:left;margin:1px 0;transition:.15s}nav button span{width:16px;text-align:center;color:#69778d;font-size:15px}nav button em{font-style:normal;margin-left:auto;background:#1c2330;color:#7e8ba0;border-radius:9px;padding:0 6px;font-size:10px}nav button:hover{background:#141923;color:#fff}nav button.active{background:#1b2030;color:#fff}nav button.active span{color:var(--purple)}.sidebarFooter{min-height:56px;border-top:1px solid var(--line);display:flex;align-items:center;padding:9px 11px;gap:9px}.avatar{width:30px;height:30px;background:#242b3b;border:1px solid #343e50;border-radius:7px;display:grid;place-items:center;font-weight:700}.usertext{flex:1;min-width:0}.usertext b{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px}.sidebarFooter button{border:0;background:transparent;color:#738096;font-size:18px}main{grid-column:2;min-width:0}.topbar{height:48px;border-bottom:1px solid var(--line);background:#0d1017e8;backdrop-filter:blur(10px);position:sticky;top:0;z-index:7;display:flex;align-items:center;justify-content:space-between;padding:0 20px}.crumb{font-size:12px;color:#b7c0ce}.topactions{display:flex;align-items:center;gap:10px}.iconbtn{width:30px;height:30px;border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:6px}.dirty{font-size:11px;color:var(--amber)}#content{padding:18px 20px 34px;max-width:1800px;margin:auto}.pagehead{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:15px}.pagehead h1{font-size:19px;margin:0 0 2px}.pagehead p{margin:0;color:var(--muted);font-size:11px}.toolbar{display:flex;align-items:center;gap:7px;flex-wrap:wrap}.btn{border:1px solid var(--line2);background:#171d29;color:#d5dbe5;padding:6px 10px;border-radius:6px;min-height:30px}.btn:hover{background:#202838;border-color:#3a465b}.btn.primary{background:#6d5dfc;border-color:#7667ff;color:white}.btn.danger{color:#ff9aaa;background:#2b171e;border-color:#592733}.btn.success{color:#8df0c7;background:#14312a;border-color:#245444}.btn.tiny{padding:3px 7px;min-height:24px;font-size:11px}.search{background:#10151e;border:1px solid var(--line);border-radius:6px;color:var(--text);padding:6px 9px;outline:0}.search:focus,input:focus,textarea:focus,select:focus{border-color:#6658d8;box-shadow:0 0 0 2px #6d5dfc18}.stats{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:10px}.stat{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:13px 14px}.stat small{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.06em}.stat strong{font-size:24px;display:block;margin:5px 0 1px}.stat .trend{font-size:11px;color:var(--muted)}.panel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.panelhead{min-height:42px;padding:10px 12px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}.panelhead h2{font-size:12px;margin:0}.twocol{display:grid;grid-template-columns:1.15fr .85fr;gap:10px;margin-top:10px}.table{width:100%;border-collapse:collapse}.table th{font-size:9px;text-transform:uppercase;letter-spacing:.06em;color:#657187;font-weight:600;text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);background:#10151e}.table td{padding:9px 10px;border-bottom:1px solid #202735;vertical-align:middle}.table tr:last-child td{border-bottom:0}.table tbody tr:hover{background:#151b26}.table .clickrow{cursor:pointer}.namecell{display:flex;align-items:center;gap:8px}.cube{width:23px;height:23px;border-radius:5px;background:#202839;border:1px solid #313c50;display:grid;place-items:center;font-size:10px;color:#9dabbd}.status{display:inline-flex;align-items:center;gap:5px;font-size:10px;text-transform:capitalize}.status:before{content:"";width:6px;height:6px;border-radius:50%;background:#667085}.status.running,.status.up{color:#75e3ba}.status.running:before,.status.up:before{background:var(--green);box-shadow:0 0 8px #34d39966}.status.down,.status.exited{color:#ff8999}.status.down:before,.status.exited:before{background:var(--red)}.status.pending,.status.unknown{color:#aab4c3}.status.paused{color:#aab4c3}.status.paused:before{background:#7b8799}.status.maintenance{color:#ffd86a}.status.maintenance:before{background:var(--amber)}.tag{display:inline-block;padding:2px 6px;border:1px solid #384257;border-radius:5px;background:#202736;color:#9ba7b9;font-size:10px}.muted{color:var(--muted)}.green{color:var(--green)}.red{color:var(--red)}.amber{color:var(--amber)}.splitview{display:grid;grid-template-columns:310px minmax(0,1fr);gap:10px;min-height:680px}.listpanel{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.listhead{padding:9px;border-bottom:1px solid var(--line);display:flex;gap:6px}.listhead input{width:100%;background:#0e131b;border:1px solid var(--line);color:var(--text);border-radius:5px;padding:6px 8px}.stackitem,.monitoritem{padding:10px;border-bottom:1px solid #202735;cursor:pointer}.stackitem:hover,.monitoritem:hover{background:#171e2a}.stackitem.active,.monitoritem.active{background:#1b2230;border-left:2px solid var(--purple);padding-left:8px}.itemtop{display:flex;align-items:center;justify-content:space-between;gap:8px}.itemsub{font-size:10px;color:var(--muted);margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);min-width:0;overflow:hidden}.detailhead{padding:12px 14px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;gap:12px}.detailtitle{display:flex;align-items:center;gap:10px}.detailtitle h2{font-size:15px;margin:0}.detailtitle small{color:var(--muted)}.actions{display:flex;gap:5px;flex-wrap:wrap}.tabs{display:flex;align-items:center;border-bottom:1px solid var(--line);padding:0 10px;background:#10151e;overflow:auto}.tabs button{border:0;background:transparent;color:#7f8b9e;padding:9px 10px;border-bottom:2px solid transparent;white-space:nowrap;font-size:11px}.tabs button.active{color:#fff;border-color:var(--purple)}.tabbody{padding:12px}.editorgrid{display:grid;grid-template-columns:minmax(0,1fr) 245px;gap:10px}.codebox{position:relative}.codebox textarea{width:100%;min-height:530px;resize:vertical;background:#0b0f15;border:1px solid var(--line);color:#c7d1df;border-radius:6px;padding:12px 13px;font:12px/1.6 "Cascadia Code","SFMono-Regular",Consolas,monospace;tab-size:2;outline:0}.editoraside{background:#10151e;border:1px solid var(--line);border-radius:6px;padding:10px}.editoraside h3{font-size:11px;margin:0 0 8px}.tip{padding:8px;border-radius:5px;background:#151c28;border:1px solid #252f40;color:#8f9caf;font-size:10px;margin-bottom:7px}.fieldgrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.field{display:flex;flex-direction:column;gap:5px}.field.full{grid-column:1/-1}.field label{font-size:10px;color:#8c98aa}.field input,.field select,.field textarea{background:#0e131b;border:1px solid var(--line);border-radius:5px;color:var(--text);padding:7px 8px;outline:0}.field textarea{min-height:90px;resize:vertical}.secretrow{display:grid;grid-template-columns:190px 1fr auto;gap:7px;margin-bottom:7px}.secretrow input{background:#0e131b;border:1px solid var(--line);color:var(--text);padding:7px;border-radius:5px}.servicecards{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:8px}.servicecard{padding:11px;background:#10151e;border:1px solid var(--line);border-radius:7px}.servicecard .svcmeta{display:grid;grid-template-columns:70px 1fr;gap:4px;font-size:10px;margin-top:8px}.terminal{background:#080b10;border:1px solid #202837;border-radius:6px;min-height:420px;color:#c7d1dc;font:11px/1.55 "Cascadia Code",Consolas,monospace;padding:10px;white-space:pre-wrap;overflow:auto;max-height:600px}.consolebar{display:flex;gap:6px;margin-bottom:8px}.consolebar select,.consolebar input{background:#0d121a;border:1px solid var(--line);color:var(--text);border-radius:5px;padding:6px 8px}.consolebar input{flex:1}.hb{height:28px;display:flex;align-items:flex-end;gap:2px;margin:10px 0}.hb span{flex:1;min-width:2px;height:100%;background:#283141;border-radius:2px}.hb span.up{background:#256c54}.hb span.down{background:#7b2835}.hb span.maintenance{background:#725c19}.hb span.paused{background:#3b4554}.monitorhero{padding:14px;border-bottom:1px solid var(--line);display:grid;grid-template-columns:1fr auto;gap:15px}.uptimebig{font-size:26px;font-weight:700}.chart{height:150px;border:1px solid var(--line);background:#0d1219;border-radius:6px;position:relative;overflow:hidden}.chart svg{width:100%;height:100%}.monitorstats{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:10px 0}.mini{background:#10151e;border:1px solid var(--line);padding:9px;border-radius:6px}.mini small{display:block;color:var(--muted);font-size:9px;text-transform:uppercase}.mini b{font-size:15px}.empty{padding:55px 20px;text-align:center;color:var(--muted)}.empty .big{font-size:30px;margin-bottom:7px}.modalback{position:fixed;inset:0;background:#0009;z-index:30;display:grid;place-items:center;padding:20px}.modal{width:min(680px,100%);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid #344055;border-radius:10px;box-shadow:var(--shadow)}.modalhead{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;border-bottom:1px solid var(--line)}.modalhead h2{margin:0;font-size:14px}.modalbody{padding:14px}.modalfoot{display:flex;justify-content:flex-end;gap:7px;padding:10px 14px;border-top:1px solid var(--line)}.closex{border:0;background:transparent;color:var(--muted);font-size:18px}.switch{display:flex;align-items:center;gap:7px}.switch input{accent-color:#7565ff}.notice{padding:8px 10px;border:1px solid #473b1d;background:#211c10;color:#d9bf67;border-radius:6px;font-size:10px}.draftnotice{margin-bottom:8px;padding:8px 10px;background:#201b10;border:1px solid #4c3d18;color:#e7ca67;border-radius:6px;display:flex;justify-content:space-between;align-items:center}.placeholderGrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.placeholder{height:160px;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);display:grid;place-items:center;color:#5d687a}#toast{position:fixed;right:18px;bottom:18px;z-index:50;background:#1b2230;border:1px solid #3b465b;box-shadow:var(--shadow);padding:10px 13px;border-radius:7px;display:none;max-width:440px}code{font-family:"Cascadia Code",Consolas,monospace;color:#c7d5e8}@media(max-width:1050px){#shell{grid-template-columns:1fr}.sidebar{display:none}main{grid-column:1}.stats,.monitorstats{grid-template-columns:repeat(2,1fr)}.splitview{grid-template-columns:1fr}.listpanel{max-height:300px}.twocol,.editorgrid{grid-template-columns:1fr}.editoraside{display:none}}@media(max-width:650px){#content{padding:12px}.stats,.monitorstats,.fieldgrid,.placeholderGrid{grid-template-columns:1fr}.detailhead,.pagehead{align-items:flex-start;flex-direction:column}.secretrow{grid-template-columns:1fr}.topbar{padding:0 12px}} +.graphbox{min-height:420px;background:#0b0f15;border:1px solid var(--line);border-radius:7px;overflow:auto;padding:8px}.graphbox svg{min-width:760px;width:100%;height:auto}.edge{fill:none;stroke:#475569;stroke-width:1.5}.edge.depends_on{stroke:#a78bfa}.edge.network{stroke:#60a5fa;stroke-dasharray:5 4}.edge.volume{stroke:#34d399;stroke-dasharray:3 3}.edgelabel{fill:#667085;font-size:9px}.gnode rect{fill:#151c28;stroke:#303a4a;stroke-width:1}.gnode.service rect{fill:#1b2030;stroke:#5b50bd}.gnode.network rect{stroke:#315d85}.gnode.volume rect{stroke:#2e6a55}.gnode circle{fill:#657187}.gnode circle.ok{fill:#34d399}.glabel{fill:#e9edf5;font-size:12px;font-weight:600}.gsub{fill:#8994a6;font-size:9px}.terminal.interactive{outline:none;cursor:text;user-select:text;white-space:pre-wrap;overflow-wrap:anywhere;min-height:460px}.terminal.interactive:focus{border-color:#6658d8;box-shadow:0 0 0 2px #6d5dfc18}.digest{font:10px/1.4 "Cascadia Code",Consolas,monospace;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.right{text-align:right}.full{grid-column:1/-1} +.compose-livebar{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 10px;margin-bottom:8px;background:#10151e;border:1px solid var(--line);border-radius:6px}.compose-livebar b{margin-right:10px}.compose-livegrid{display:grid;grid-template-columns:minmax(420px,1.05fr) minmax(380px,.95fr);gap:10px;align-items:start}.editorlabel{height:32px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;background:#10151e;border:1px solid var(--line);border-bottom:0;border-radius:6px 6px 0 0;font-size:10px;text-transform:uppercase;letter-spacing:.05em}.compose-livegrid .codebox textarea{border-radius:0 0 6px 6px;min-height:680px}.visualcompose{border-radius:6px;min-width:0}.visualcompose>#composeVisual{max-height:680px;overflow:auto;padding-right:2px}.compose-service{background:#10151e;border:1px solid var(--line);border-radius:7px;margin-bottom:8px;overflow:hidden}.compose-service-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:9px 10px;border-bottom:1px solid var(--line);background:#131a25}.compose-service-head>div{display:flex;align-items:center;gap:8px}.compose-service .fieldgrid{padding:10px}.compose-service textarea{min-height:72px;font:11px/1.5 "Cascadia Code",Consolas,monospace}.compose-service input:disabled,.compose-service textarea:disabled{opacity:.65;cursor:not-allowed;border-style:dashed}.compose-preserved{padding:7px 10px;border-top:1px solid #40391f;background:#1c190f;color:#cdbb72;font-size:10px}.compose-parseerror{padding:18px;border:1px solid #572633;background:#29161c;color:#f3a1af;border-radius:6px}.compose-parseerror p{margin:5px 0;color:#d58d99}.compose-help{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}.compose-help span{font-size:10px;color:var(--muted);background:#10151e;border:1px solid var(--line);border-radius:5px;padding:5px 7px}@media(max-width:1250px){.compose-livegrid{grid-template-columns:1fr}.visualcompose>#composeVisual{max-height:none}.compose-livegrid .codebox textarea{min-height:500px}} +.compose-fullgrid{grid-template-columns:minmax(440px,1fr) minmax(520px,1.15fr)} +.compose-sections{display:flex;gap:4px;flex-wrap:wrap;padding:6px 0 8px}.compose-sections button{border:1px solid var(--line);background:#10151e;color:var(--muted);padding:5px 8px;border-radius:5px;font-size:10px;cursor:pointer}.compose-sections button.active{color:var(--text);border-color:#3d6ea8;background:#152235} +.compose-object{border:1px solid var(--line);border-radius:7px;background:#10151e;margin-bottom:8px;overflow:hidden}.compose-object>summary{display:flex;align-items:center;justify-content:space-between;gap:8px;cursor:pointer;padding:8px 10px;background:#131a25;list-style:none}.compose-object>summary::-webkit-details-marker{display:none}.compose-object-body{padding:9px} +.compose-fieldrow{border:1px solid #202a38;border-radius:6px;padding:7px;margin-bottom:7px;background:#0d121a}.compose-fieldrow.complex{background:#0f151e}.compose-fieldhead{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;margin-bottom:6px}.compose-fieldhead>div:first-child{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.compose-fieldhead small{display:block;width:100%;color:var(--muted);font-size:9px}.summary-actions{display:flex;align-items:center;gap:5px}.typepill{font-size:9px;color:var(--muted);border:1px solid var(--line);padding:2px 5px;border-radius:999px}.iconbtn{border:0;background:transparent;color:var(--muted);cursor:pointer;font-size:15px;line-height:1}.iconbtn:hover{color:#ff7b8a} +.compose-scalar{display:grid;grid-template-columns:1fr auto;gap:6px;align-items:start}.compose-scalar textarea{min-height:82px;font:11px/1.45 "Cascadia Code",Consolas,monospace}.typeswitch{width:88px}.nullvalue{display:flex;align-items:center;height:32px;color:var(--muted);font-family:monospace} +.compose-array{display:flex;flex-direction:column;gap:6px}.compose-arrayitem{border-left:2px solid #26364c;padding-left:8px}.arrayindex{display:flex;align-items:center;justify-content:space-between;color:var(--muted);font-size:9px;margin-bottom:4px}.compose-map{display:flex;flex-direction:column;gap:0}.compose-addrow{display:grid;grid-template-columns:minmax(120px,1fr) 90px auto;gap:5px;margin-top:6px}.compose-addrow input,.compose-addrow select{min-width:0}.compose-named>.compose-object-body>.compose-map{padding-top:0} +@media(max-width:1450px){.compose-fullgrid{grid-template-columns:1fr}.visualcompose>#composeVisual{max-height:none}} + +/* v9 UX refinements */ +:root[data-theme="light"]{--bg:#f5f7fb;--side:#ffffff;--panel:#ffffff;--panel2:#f6f8fc;--panel3:#eef2f8;--line:#dfe5ee;--line2:#cbd5e1;--text:#18202c;--muted:#667085;--green2:#e7f7f0;--red2:#fff0f2;--amber2:#fff7df;--shadow:0 16px 44px #22304a18} +:root[data-theme="light"] .topbar{background:#f5f7fbe8}:root[data-theme="light"] .table th{background:#f7f9fc}:root[data-theme="light"] .environment,:root[data-theme="light"] .listhead input,:root[data-theme="light"] .search{background:#f8fafc}:root[data-theme="light"] .terminal{background:#10151d;color:#e8edf5} +.brandcopy{min-width:0;flex:1}.collapseBtn{border:0;background:transparent;color:var(--muted);font-size:20px;width:24px;height:30px;border-radius:6px}.collapseBtn:hover{background:var(--panel2);color:var(--text)}.sidebarMeta{padding:5px 12px;color:var(--muted);font-size:10px;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.apiState{display:inline-flex;align-items:center;gap:5px;color:var(--muted);font-size:10px}.apiState i{width:6px;height:6px;border-radius:50%;background:var(--green);box-shadow:0 0 8px #34d39966}.apiState.offline{color:var(--red)}.apiState.offline i{background:var(--red);box-shadow:none}.btn[disabled],.iconbtn[disabled]{opacity:.55;cursor:wait}.btn.busy:after{content:"";display:inline-block;width:9px;height:9px;border:1.5px solid currentColor;border-right-color:transparent;border-radius:50%;margin-left:7px;vertical-align:-1px;animation:spin .7s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} +.stats{grid-template-columns:repeat(auto-fit,minmax(165px,1fr))}.consolebar .spacer{flex:1}.logstate{font-size:10px;color:var(--muted);display:inline-flex;align-items:center;gap:5px}.logstate:before{content:"";width:6px;height:6px;border-radius:50%;background:#667085}.logstate.live:before{background:var(--green)}.logstate.reconnecting:before{background:var(--amber)}.logstate.paused:before{background:var(--amber)}.resourcebar{display:flex;gap:8px;align-items:center;padding:9px 10px;border-bottom:1px solid var(--line)}.resourcebar input{max-width:360px;width:100%;background:var(--panel2);border:1px solid var(--line);color:var(--text);border-radius:6px;padding:6px 9px}.healthDetail{font-size:10px;color:var(--muted);margin-top:3px}.keyboardHint{font-size:10px;color:var(--muted)}kbd{font:10px ui-monospace,SFMono-Regular,Consolas,monospace;border:1px solid var(--line2);border-bottom-width:2px;border-radius:4px;padding:1px 4px;background:var(--panel2)} +body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collapsed .sidebar{width:64px}body.sidebar-collapsed .brand{padding:13px 14px}body.sidebar-collapsed .brandcopy,body.sidebar-collapsed .environment>div,body.sidebar-collapsed .navlabel,body.sidebar-collapsed nav button:not(.active) em,body.sidebar-collapsed nav button em,body.sidebar-collapsed nav button{font-size:0}body.sidebar-collapsed nav button{justify-content:center;padding:8px}body.sidebar-collapsed nav button span{font-size:16px;width:auto}body.sidebar-collapsed .environment{justify-content:center;padding:10px;margin:10px}body.sidebar-collapsed .usertext,body.sidebar-collapsed .sidebarFooter #logout,body.sidebar-collapsed .sidebarMeta{display:none}body.sidebar-collapsed .sidebarFooter{justify-content:center}body.sidebar-collapsed .collapseBtn{position:absolute;left:52px;top:16px;background:var(--panel);border:1px solid var(--line);font-size:0;width:22px;height:26px}body.sidebar-collapsed .collapseBtn:after{content:"›";font-size:18px}body.sidebar-collapsed main{grid-column:2} +@media(max-width:860px){#shell{display:block}.sidebar{transform:translateX(-100%);transition:transform .18s;width:218px;box-shadow:var(--shadow)}body.sidebar-mobile-open .sidebar{transform:translateX(0)}main{grid-column:auto}.topbar{padding-left:50px}.topbar:before{content:"☰";position:absolute;left:14px;font-size:19px;color:var(--muted);cursor:pointer}.splitview{grid-template-columns:1fr}.listpanel{max-height:300px}.twocol{grid-template-columns:1fr}.compose-livegrid{grid-template-columns:1fr!important}} +.topLeft{display:flex;align-items:center;gap:8px}.mobileMenu{display:none}@media(max-width:860px){.topbar{padding-left:12px}.topbar:before{display:none}.mobileMenu{display:inline-grid;place-items:center}} +.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)}