@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
*.zip
|
||||
dockwatch
|
||||
data/
|
||||
stacks/
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
data/
|
||||
stacks/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
dockwatch
|
||||
+22
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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/<slug>
|
||||
/public/api/status/<slug>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
+23
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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<?`, cut)
|
||||
}
|
||||
cleanup()
|
||||
t := time.NewTicker(24 * time.Hour)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.send.nrw/sendnrw/dockwatch/internal/config"
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userKey contextKey = "user"
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
db *sql.DB
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauth oauth2.Config
|
||||
dev User
|
||||
}
|
||||
|
||||
func New(ctx context.Context, c config.Config, db *sql.DB) (*Service, error) {
|
||||
s := &Service{cfg: c, db: db}
|
||||
if c.Mode == config.ModeAgent {
|
||||
return s, nil
|
||||
}
|
||||
if c.AuthDisabled {
|
||||
now := time.Now().Unix()
|
||||
_, e := db.ExecContext(ctx, `INSERT INTO users(oidc_sub,email,name,role,last_login_at,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(oidc_sub) DO UPDATE SET last_login_at=excluded.last_login_at`, "dev", "dev@local", "Development Admin", "admin", now, now)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
e = db.QueryRowContext(ctx, `SELECT id,oidc_sub,email,name,role FROM users WHERE oidc_sub='dev'`).Scan(&s.dev.ID, &s.dev.Sub, &s.dev.Email, &s.dev.Name, &s.dev.Role)
|
||||
return s, e
|
||||
}
|
||||
dctx, cancel := context.WithTimeout(ctx, c.HTTPTimeout)
|
||||
defer cancel()
|
||||
p, e := oidc.NewProvider(dctx, c.OIDCIssuer)
|
||||
if e != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", e)
|
||||
}
|
||||
s.verifier = p.Verifier(&oidc.Config{ClientID: c.OIDCClientID})
|
||||
s.oauth = oauth2.Config{ClientID: c.OIDCClientID, ClientSecret: c.OIDCClientSecret, Endpoint: p.Endpoint(), RedirectURL: c.OIDCRedirectURL, Scopes: []string{oidc.ScopeOpenID, "profile", "email", "groups"}}
|
||||
return s, nil
|
||||
}
|
||||
func (s *Service) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AuthDisabled {
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
state, err := token(24)
|
||||
if err != nil {
|
||||
http.Error(w, "could not initialize login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
nonce, err := token(24)
|
||||
if err != nil {
|
||||
http.Error(w, "could not initialize login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.temp(w, "dw_state", state)
|
||||
s.temp(w, "dw_nonce", nonce)
|
||||
http.Redirect(w, r, s.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), 302)
|
||||
}
|
||||
func (s *Service) Callback(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.HTTPTimeout)
|
||||
defer cancel()
|
||||
sc, e := r.Cookie("dw_state")
|
||||
if e != nil || sc.Value != r.URL.Query().Get("state") {
|
||||
return errors.New("invalid oidc state")
|
||||
}
|
||||
nc, e := r.Cookie("dw_nonce")
|
||||
if e != nil {
|
||||
return errors.New("missing nonce")
|
||||
}
|
||||
tok, e := s.oauth.Exchange(ctx, r.URL.Query().Get("code"))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
raw, ok := tok.Extra("id_token").(string)
|
||||
if !ok {
|
||||
return errors.New("missing id_token")
|
||||
}
|
||||
id, e := s.verifier.Verify(ctx, raw)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var c struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Preferred string `json:"preferred_username"`
|
||||
Nonce string `json:"nonce"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
if e = id.Claims(&c); e != nil {
|
||||
return e
|
||||
}
|
||||
if c.Nonce != nc.Value {
|
||||
return errors.New("invalid nonce")
|
||||
}
|
||||
s.clearTemp(w, "dw_state")
|
||||
s.clearTemp(w, "dw_nonce")
|
||||
if c.Name == "" {
|
||||
c.Name = c.Preferred
|
||||
}
|
||||
role := "viewer"
|
||||
if slices.Contains(c.Groups, s.cfg.OIDCOperatorGroup) {
|
||||
role = "operator"
|
||||
}
|
||||
if slices.Contains(c.Groups, s.cfg.OIDCAdminGroup) {
|
||||
role = "admin"
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
_, e = s.db.ExecContext(ctx, `INSERT INTO users(oidc_sub,email,name,role,last_login_at,created_at) VALUES(?,?,?,?,?,?) ON CONFLICT(oidc_sub) DO UPDATE SET email=excluded.email,name=excluded.name,role=excluded.role,last_login_at=excluded.last_login_at`, c.Sub, c.Email, c.Name, role, now, now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
var uid int64
|
||||
if e = s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE oidc_sub=?`, c.Sub).Scan(&uid); e != nil {
|
||||
return e
|
||||
}
|
||||
v, e := token(32)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h := sha256.Sum256([]byte(v))
|
||||
exp := time.Now().Add(12 * time.Hour)
|
||||
_, e = s.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,expires_at,created_at) VALUES(?,?,?,?)`, h[:], uid, exp.Unix(), now)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "dockwatch_session", Value: v, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, Expires: exp, MaxAge: int(time.Until(exp).Seconds())})
|
||||
return nil
|
||||
}
|
||||
func (s *Service) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, e := r.Cookie("dockwatch_session"); e == nil {
|
||||
h := sha256.Sum256([]byte(c.Value))
|
||||
_, _ = s.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, h[:])
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "dockwatch_session", Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||||
}
|
||||
func (s *Service) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AuthDisabled {
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, s.dev)))
|
||||
return
|
||||
}
|
||||
c, e := r.Cookie("dockwatch_session")
|
||||
if e != nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
h := sha256.Sum256([]byte(c.Value))
|
||||
var u User
|
||||
e = s.db.QueryRowContext(r.Context(), `SELECT u.id,u.oidc_sub,u.email,u.name,u.role FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_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})
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<?`, time.Now().Unix())
|
||||
if s.retentionDays <= 0 {
|
||||
return
|
||||
}
|
||||
cut := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).Unix()
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM monitor_checks WHERE checked_at<?`, cut)
|
||||
}
|
||||
func ParseID(v string) (int64, error) {
|
||||
id, e := strconv.ParseInt(v, 10, 64)
|
||||
if e != nil || id < 1 {
|
||||
return 0, fmt.Errorf("invalid id")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func bytesTrimSpace(b []byte) []byte { return []byte(strings.TrimSpace(string(b))) }
|
||||
@@ -0,0 +1,84 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }))
|
||||
defer srv.Close()
|
||||
c := Probe(context.Background(), Input{Type: "http", Target: srv.URL, TimeoutMS: 1000, ExpectedMin: 200, ExpectedMax: 299})
|
||||
if !c.OK || c.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("unexpected check: %+v", c)
|
||||
}
|
||||
}
|
||||
func TestTCPProbe(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
c := Probe(context.Background(), Input{Type: "tcp", Target: ln.Addr().String(), TimeoutMS: 1000})
|
||||
if !c.OK {
|
||||
t.Fatalf("unexpected check: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateServiceStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
monitors []Monitor
|
||||
want string
|
||||
}{
|
||||
{"all up", []Monitor{{Status: "up"}, {Status: "up"}}, "up"},
|
||||
{"one fault", []Monitor{{Status: "up"}, {Status: "down"}}, "down"},
|
||||
{"maintenance", []Monitor{{Status: "up"}, {Status: "maintenance"}}, "maintenance"},
|
||||
{"empty", nil, "unknown"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := aggregateStatus(tc.monitors); got != tc.want {
|
||||
t.Fatalf("got %s want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerProbeRunningAndHealthy(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell fixture")
|
||||
}
|
||||
d := t.TempDir()
|
||||
path := filepath.Join(d, "docker")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf '%s\\n' '{\"Running\":true,\"Status\":\"running\",\"Health\":{\"Status\":\"healthy\"}}'\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", d+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
c := Probe(context.Background(), Input{Type: "docker", Target: "app", TimeoutMS: 1000, RequireHealthy: true})
|
||||
if !c.OK {
|
||||
t.Fatalf("expected healthy docker probe, got %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerProbeHealthRequiredWithoutHealthcheck(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell fixture")
|
||||
}
|
||||
d := t.TempDir()
|
||||
path := filepath.Join(d, "docker")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nprintf '%s\\n' '{\"Running\":true,\"Status\":\"running\",\"Health\":null}'\n"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", d+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
c := Probe(context.Background(), Input{Type: "docker", Target: "app", TimeoutMS: 1000, RequireHealthy: true})
|
||||
if c.OK || c.Message != "container has no healthcheck" {
|
||||
t.Fatalf("unexpected docker check: %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package nodes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
type storedNode struct {
|
||||
Node
|
||||
Token string
|
||||
}
|
||||
type Manager struct {
|
||||
db *sql.DB
|
||||
key []byte
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func New(db *sql.DB, key []byte) *Manager {
|
||||
return &Manager{db: db, key: key, client: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
func (m *Manager) List(ctx context.Context) ([]Node, error) {
|
||||
rows, err := m.db.QueryContext(ctx, `SELECT id,name,base_url,enabled,created_at,updated_at FROM nodes ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Node{}
|
||||
for rows.Next() {
|
||||
var n Node
|
||||
if err := rows.Scan(&n.ID, &n.Name, &n.BaseURL, &n.Enabled, &n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func validateBaseURL(baseURL string) error {
|
||||
u, err := url.Parse(baseURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return errors.New("base_url must be an absolute http(s) URL without credentials, query or fragment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Create(ctx context.Context, name, baseURL, token string) (Node, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if name == "" || len(name) > 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+173
@@ -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 `<span class="status ${esc(s||'unknown')}">${esc(s||'unknown')}</span>`}
|
||||
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=`<option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
|
||||
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
|
||||
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
|
||||
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitors: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Services: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}}
|
||||
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,activity:renderActivity}[state.view]||renderDashboard)()}
|
||||
function pageHead(title,sub,actions=''){return `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
|
||||
function renderDashboard(){setCrumb('Dashboard');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Dashboard',nodeName()+' · Docker & Uptime overview','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Refresh</button>')}<div class="stats"><div class="stat"><small>Compose stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} running</span></div></div><div class="stat"><small>Monitors up</small><strong>${up}</strong><div class="trend">${state.monitors.length} configured</div></div><div class="stat"><small>Monitor incidents</small><strong class="${down?'red':''}">${down}</strong><div class="trend">current probe failures</div></div><div class="stat"><small>Services</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} degraded</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose stacks</h2><button class="btn tiny" id="goStacks">View all</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime monitors</h2><button class="btn tiny" id="goMons">View all</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
|
||||
function stackTable(items){if(!items.length)return'<div class="empty">No compose stacks found.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Services</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
|
||||
function monitorTable(items){if(!items.length)return'<div class="empty">No monitors configured.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Type</th><th>Uptime 24h</th></tr></thead><tbody>${items.map(m=>`<tr class="clickrow" data-openmon="${m.id}"><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td><span class="tag">${esc(m.type.toUpperCase())}</span></td><td>${Number(m.uptime_24h||0).toFixed(2)}%</td></tr>`).join('')}</tbody></table>`}
|
||||
function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'<button class="btn primary" id="newStack">+ New stack</button>':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="stackSearch" placeholder="Filter stacks…"><button class="btn tiny" id="stackRefresh">↻</button></div><div id="stackList">${renderStackList(state.stacks)}</div></div><div id="stackDetail" class="detail">${state.stack?stackDetailHTML(state.stack):'<div class="empty"><div class="big">▱</div>Select a stack or create a new one.</div>'}</div></div>`;$('#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=>`<div class="stackitem ${state.stack?.name===s.name?'active':''}" data-stack="${esc(s.name)}"><div class="itemtop"><b>${esc(s.name)}</b>${badge(s.status)}</div><div class="itemsub">${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}</div></div>`).join('')||'<div class="empty">No stacks</div>'}
|
||||
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 `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'New compose stack')}</h2><small>${st.name?esc(nodeName()):'Draft · not deployed'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Deploy</button><button class="btn" data-act="pull">↓ Pull</button><button class="btn" data-act="update">↻ Update</button><button class="btn" data-act="restart">⟳ Restart</button><button class="btn" data-act="stop">■ Stop</button>`:''}<button class="btn primary" id="saveStack">Save</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env files</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Containers <span class="tag">${sv.length}</span></button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Danger zone</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
|
||||
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>A local unsaved draft exists for this stack.</span><span><button class="btn tiny" id="restoreDraft">Restore</button> <button class="btn tiny" id="discardDraft">Discard</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Full Compose Designer</b><span id="composeSyncState" class="muted"> parsing…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Expand all</button><button class="btn tiny" id="composeCollapseAll">Collapse all</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Source of truth</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visual editor <span class="muted">all fields · AST patch mode</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Parsing Compose…</div></div></div></div><div class="compose-help"><span>Every present Compose value is editable as string, number, boolean, null, map or array.</span><span>Current spec fields are suggested; x-* and future fields remain editable too.</span><span>Invalid YAML pauses visual sync without replacing your source.</span></div>`}
|
||||
|
||||
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;i<path.length-1;i++){const k=path[i];if(Array.isArray(cur))cur=cur[Number(k)];else cur=cur[k];if(cur==null)return}const k=path[path.length-1];if(del){if(Array.isArray(cur))cur.splice(Number(k),1);else delete cur[k]}else{if(Array.isArray(cur))cur[Number(k)]=val;else cur[k]=val}}
|
||||
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' parsing…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronized';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visual sync paused';box.innerHTML=`<div class="compose-parseerror"><b>Visual editor paused</b><p>${esc(e.message)}</p><p>Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.</p></div>`}}
|
||||
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])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).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||'<div class="empty">Nothing configured in this section.</div>';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])=>`<details class="compose-object compose-named" open><summary><span><b>${esc(k)}</b> <span class="tag">${esc(label)}</span></span><span class="summary-actions"><button class="iconbtn" data-cdelete="${pathAttr([...path,k])}" title="Remove">×</button></span></summary><div class="compose-object-body">${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}</div></details>`).join('');return `${rows}<div class="compose-addrow"><input data-cnewname="${pathAttr(path)}" placeholder="New ${esc(label)} name"><button class="btn tiny" data-caddnamed="${pathAttr(path)}" data-ctype="map">+ ${esc(label)}</button></div>`}
|
||||
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 `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Field</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
|
||||
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">extension</span>':''}</div><div class="summary-actions"><span class="typepill">${t}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Remove">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
|
||||
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 `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Remove">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Item</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
|
||||
function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitch="${pathAttr(path)}"><option value="string" ${t==='string'?'selected':''}>string</option><option value="number" ${t==='number'?'selected':''}>number</option><option value="boolean" ${t==='boolean'?'selected':''}>boolean</option><option value="map" ${t==='map'?'selected':''}>object</option><option value="array" ${t==='array'?'selected':''}>array</option><option value="null" ${t==='null'?'selected':''}>null</option></select>`}
|
||||
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 `<div class="field"><label>.env · Compose variable substitution</label><textarea id="envText" class="terminal" style="min-height:500px" spellcheck="false" placeholder="POSTGRES_TAG=16-alpine\nAPP_PORT=8080">${esc(st.env||'')}</textarea></div>`}
|
||||
function secretsTab(st){return `<div class="notice" style="margin-bottom:10px">Secret values are written with mode 0600. Use Compose <code>secrets:</code> with <code>file: ./secrets/name</code>. Existing values are visible only to operators who can edit the stack.</div><div id="secretList">${(st.secrets||[]).map(secretRow).join('')}</div><button class="btn" id="addSecret">+ Add secret file</button>`}
|
||||
function secretRow(s={}){return `<div class="secretrow"><input class="secName" placeholder="db_password" value="${esc(s.name||'')}"><input class="secContent" type="password" placeholder="secret value" value="${esc(s.content||'')}"><button class="btn danger removeSecret">Remove</button></div>`}
|
||||
function managedFilesTab(kind,files,title,help){return `<div class="notice" style="margin-bottom:10px"><b>${title}</b> · ${help} Managed files are validated together with the stack.</div><div id="${kind}List">${files.map(f=>managedFileRow(kind,f)).join('')}</div><button class="btn" id="add${kind}">+ Add file</button>`}
|
||||
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `<div class="secretrow ${cls}"><input class="managedName" placeholder="${kind==='envfile'?'app.env':'nginx.conf'}" value="${esc(f.name||'')}"><textarea class="managedContent" placeholder="file contents">${esc(f.content||'')}</textarea><button class="btn danger removeManaged">Remove</button></div>`}
|
||||
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'<div class="empty">This stack has no running containers yet.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span><span>${esc(v.image||'—')}</span><span class="muted">Ports</span><span>${esc(v.ports||'—')}</span><span class="muted">Command</span><span>${esc(v.command||'—')}</span></div></div>`).join('')}</div>`}
|
||||
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">Load 500</button><button class="btn success" id="liveLogs">▶ Follow</button><button class="btn" id="pauseLogs">Ⅱ Pause</button><button class="btn danger" id="stopLogs">■ Stop</button><input id="logFilter" placeholder="Filter visible logs…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Auto-scroll</label><button class="btn" id="downloadLogs">Download</button><span id="logState" class="logstate">idle</span></div><pre id="logBox" class="terminal">Select “Load” or “Follow”.</pre>`}
|
||||
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Build dependency graph</button></div><div id="graphBox" class="graphbox"><div class="empty">Load the normalized Compose dependency graph.</div></div>`}
|
||||
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Check registry updates</button></div><div id="updateBox"><div class="empty">Compare installed image digests with registry manifests.</div></div>`}
|
||||
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Connect</button><button class="btn danger" id="closeTerminal">Disconnect</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
|
||||
</div>`}
|
||||
function dangerTab(st){return st.name?`<div class="notice"><b>Safe delete is the default.</b> It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose down</button><button class="btn" data-act="recreate">Force recreate</button><button class="btn danger" id="deleteStack">Delete definition</button><button class="btn danger" id="purgeStack">Purge entire folder…</button></div>`:'<div class="empty">Save the stack first.</div>'}
|
||||
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()?'<button class="btn primary" id="newMonitor">+ New monitor</button>':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="monSearch" placeholder="Filter monitors…"><button class="btn tiny" id="monRefresh">↻</button></div><div id="monList">${monitorListHTML(state.monitors)}</div></div><div id="monDetail" class="detail">${state.monitor?monitorDetailHTML(state.monitor,state.checks):'<div class="empty"><div class="big">♡</div>Select a monitor to inspect uptime and latency.</div>'}</div></div>`;$('#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=>`<div class="monitoritem ${state.monitor?.id===m.id?'active':''}" data-mon="${m.id}"><div class="itemtop"><b>${esc(m.name)}</b>${badge(m.status)}</div><div class="itemsub">${esc(m.type.toUpperCase())} · ${esc(m.target)}</div><div class="hb">${heartbeatBars([],m.status,24)}</div></div>`).join('')||'<div class="empty">No monitors</div>'}
|
||||
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},()=>`<span class="${status==='up'?'up':status==='down'?'down':status==='maintenance'?'maintenance':'paused'}"></span>`).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'<span></span>').join('')+a.map(c=>`<span class="${c.ok?'up':'down'}" title="${esc(fmtTime(c.checked_at))} · ${c.latency_ms}ms"></span>`).join('')}
|
||||
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'<div class="empty">No heartbeat data yet.</div>';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 `<svg viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="#7c6cf7" stroke-width="1.3" vector-effect="non-scaling-stroke"/><line x1="0" x2="100" y1="95" y2="95" stroke="#273042" stroke-width=".5"/></svg>`}
|
||||
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 `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Check now</button><button class="btn" id="editMon">Edit</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Resume</button>':'<button class="btn" id="pauseMon">Pause</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">End maintenance</button>':'<button class="btn" id="maintMon">Maintenance</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">24 hour uptime</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Last heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Last latency</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Average latency</small><b>${avg} ms</b></div><div class="mini"><small>Recent success</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Interval</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Last result:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Response time</h2><span class="muted">last ${c.length} heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Configuration</h2></div><table class="table"><tbody><tr><td class="muted">Type</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Method</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Expected status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Keyword</td><td>${esc(m.keyword||'—')}</td><td class="muted">Probe environment</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Delete monitor</button></div>':''}</div>`}
|
||||
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(`<div class="modalhead"><h2>${isEdit?'Edit monitor':'New monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor type</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP port</option><option value="dns">DNS hostname</option><option value="docker">Docker container</option></select></div><div class="field full"><label>URL / target / container name or ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://example.com, host:port or container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Service group</label><select id="mfService"><option value="">Ungrouped</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Probe environment</label><select id="mfNode"><option value="">Master / local</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Interval (seconds)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP method</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Expected HTTP status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Keyword assertion (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text that must occur in the response"></div><div class="field full"><label>Request headers as JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Request body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Invert keyword match</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> Ignore TLS certificate errors</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: require Healthy condition</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveMonitor">${isEdit?'Save changes':'Create monitor'}</button></div>`);$('#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=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).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(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.</div><div class="fieldgrid"><div class="field"><label>End mode</label><select id="maintMode"><option value="manual">Until manually ended</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="24h">24 hours</option><option value="custom">Custom date/time</option></select></div><div class="field"><label>Custom end</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Note</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="startMaint">Start maintenance</button></div>`);$('#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()?'<button class="btn primary" id="newService">+ New service</button>':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}<div class="panel" id="servicePanel">${state.services.length?`<table class="table"><thead><tr><th>Service</th><th>Status</th><th>Probes</th><th></th></tr></thead><tbody>${state.services.map(g=>`<tr><td><b>${esc(g.name)}</b><div class="muted">${esc(g.description||'')}</div></td><td>${badge(g.status)}</td><td>${(g.monitors||[]).length}<div class="muted">${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}</div></td><td class="right">${roleOK()?`<button class="btn tiny" data-sedit="${g.id}">Edit</button> <button class="btn tiny danger" data-sdel="${g.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No services configured. Create a service and assign probes to it.</div>'}</div>`;$('#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(`<div class="modalhead"><h2>${x?'Edit':'New'} service</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Name</label><input id="svcName" value="${esc(x?.name||'')}"></div><div class="field"><label>Description</label><textarea id="svcDesc">${esc(x?.description||'')}</textarea></div><div class="field"><label>Assigned probes</label><div class="checklist serviceProbeList">${state.monitors.map(m=>`<label class="switch"><input class="svcProbe" type="checkbox" value="${m.id}" ${selected.has(m.id)?'checked':''}> <span>${esc(m.name)} · ${esc(m.type.toUpperCase())}</span> ${badge(m.status)}</label>`).join('')||'<span class="muted">No probes configured.</span>'}</div></div><div class="notice">A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="svcSave">Save</button></div>`);$('#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'?'<button class="btn primary" id="newStatusPage">+ New status page</button>':'')}<div class="panel" id="statusPagePanel"><div class="empty">Loading status pages…</div></div>`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Public URL</th><th>Services</th><th>Enabled</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b><div class="muted">${esc(x.description||'')}</div></td><td><a href="/status/${encodeURIComponent(x.slug)}" target="_blank">/status/${esc(x.slug)}</a></td><td>${(x.service_ids||[]).length}</td><td>${x.enabled?'<span class="green">Public</span>':'Disabled'}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-pedit="${x.id}">Edit</button> <button class="btn tiny danger" data-pdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No public status pages configured.</div>';$$('[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=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`<div class="modalhead"><h2>${x?'Edit':'New'} public status page</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="pgName" value="${esc(x?.name||'')}"></div><div class="field"><label>Slug</label><input id="pgSlug" value="${esc(x?.slug||'')}" placeholder="public-status"></div><div class="field full"><label>Description</label><textarea id="pgDesc">${esc(x?.description||'')}</textarea></div><div class="field full"><label>Published services</label><div class="checklist">${state.services.map(g=>`<label class="switch"><input class="pgSvc" type="checkbox" value="${g.id}" ${selected.has(g.id)?'checked':''}> ${esc(g.name)} ${badge(g.status)}</label>`).join('')||'<span class="muted">Create services first.</span>'}</div></div><label class="switch"><input id="pgEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Publicly accessible</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="pgSave">Save</button></div>`);$('#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.')}<div class="panel"><div class="panelhead"><h2>Active maintenance windows</h2></div>${ms.length?`<table class="table"><thead><tr><th>Monitor</th><th>Target</th><th>Until</th><th>Note</th><th></th></tr></thead><tbody>${ms.map(m=>`<tr><td><b>${esc(m.name)}</b></td><td class="muted">${esc(m.target)}</td><td>${m.maintenance_until?fmtTime(m.maintenance_until):'Manual end'}</td><td>${esc(m.maintenance_note||'—')}</td><td><button class="btn tiny" data-endmaint="${m.id}">End</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No active maintenance windows.</div>'}</div><div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Start maintenance</h2></div><table class="table"><tbody>${state.monitors.filter(m=>m.status!=='maintenance').map(m=>`<tr><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td style="text-align:right"><button class="btn tiny" data-startmaint="${m.id}">Schedule</button></td></tr>`).join('')}</tbody></table></div>`;$$('[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=`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>Local Docker</b><div class="muted">Docker socket</div></div></div></td><td>${badge('up')}<div class="healthDetail">${esc(state.system?.build?.version||'local')}</div></td><td>Local</td><td>—</td></tr>`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'<button class="btn primary" id="addNode">+ Add environment</button>':'')}<div class="panel"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Connection</th><th>Actions</th></tr></thead><tbody>${local}${state.nodes.map(n=>`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>${esc(n.name)}</b><div class="muted">${esc(n.base_url)}</div></div></div></td><td id="nodeHealth-${n.id}">${n.enabled?'<span class="status unknown">Checking…</span>':badge('paused')}</td><td>${n.enabled?'Bearer agent':'Disabled'}</td><td>${state.me.role==='admin'?`<button class="btn tiny" data-editnode="${n.id}">Edit</button> <button class="btn tiny danger" data-delnode="${n.id}">Remove</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$('#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(`<div class="modalhead"><h2>${x?'Edit':'Add'} remote agent</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent base URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent enabled</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveNode">${x?'Save':'Add environment'}</button></div>`);$('#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')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(e.message)}">unreachable</div>`}}))}
|
||||
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='<button class="btn primary" id="resourceCreate">↓ Pull image</button><button class="btn" id="registryLogin">Registry login</button><button class="btn" id="registryLogout">Logout</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Create volume</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Create network</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Prune unused</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Refresh</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="Filter ${n.toLowerCase()}…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">Loading ${n.toLowerCase()}…</div></div></div>`;$('#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=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
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='<div class="empty">No matching items found.</div>';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=`<table class="table"><thead><tr>${cols.map(c=>`<th>${esc(c[1])}</th>`).join('')}<th class="right">Actions</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td class="${i?'muted':''}">${i===0?`<div class="namecell"><span class="cube">⬡</span><b>${esc(r[c[0]]||'—')}</b></div>`:esc(r[c[0]]||'—')}</td>`).join('')}<td class="right">${resourceActions(kind,r,idx)}</td></tr>`}).join('')}</tbody></table>`;wireResourceRows(kind)}
|
||||
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspect</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-ract="start" data-row="${idx}">Start</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Restart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stop</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`}
|
||||
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(`<div class="modalhead"><h2>Registry login</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Username</label><input id="regUser"></div><div class="field"><label>Password / token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">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.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="regSave">Login</button></div>`);$('#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(`<div class="modalhead"><h2>Registry logout</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="regSave">Logout</button></div>`);$('#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(`<div class="modalhead"><h2>Pull image</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image reference</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Pull</button></div>`);$('#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(`<div class="modalhead"><h2>Create ${isNet?'network':'volume'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Driver</label><input id="resDriver" placeholder="${isNet?'bridge':'local'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internal network</label><label class="switch"><input id="resAttachable" type="checkbox"> Attachable</label>':''}<div class="field full"><label>Labels (key=value, one per line)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Create</button></div>`);$('#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(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Memory</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Network I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect JSON <span class="muted">(operator/admin only; may contain sensitive environment values)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}}
|
||||
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
|
||||
|
||||
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='<div class="empty">Resolving Compose model…</div>';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='<div class="empty">No graph nodes.</div>';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 `<path d="M${a.x+100},${a.y} C${a.x+240},${a.y} ${b.x-240},${b.y} ${b.x-100},${b.y}" class="edge ${esc(e.kind)}"/><text x="${(a.x+b.x)/2}" y="${(a.y+b.y)/2-4}" class="edgelabel">${esc(e.kind)}</text>`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `<g class="gnode ${esc(n.kind)}"><rect x="${p.x-100}" y="${p.y-24}" width="200" height="48" rx="7"/><circle cx="${p.x-80}" cy="${p.y}" r="5" class="${n.running?'ok':''}"/><text x="${p.x-67}" y="${p.y-3}" class="glabel">${esc(n.label)}</text><text x="${p.x-67}" y="${p.y+12}" class="gsub">${esc(n.image||n.kind)}</text></g>`}).join('');box.innerHTML=`<svg viewBox="0 0 ${w} ${h}" role="img">${lines}${ns}</svg>`}
|
||||
async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='<div class="empty">Checking registry manifests…</div>';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`<table class="table"><thead><tr><th>Service</th><th>Image</th><th>Local digest</th><th>Remote digest</th><th>Status</th></tr></thead><tbody>${rows.map(r=>`<tr><td><b>${esc(r.service)}</b></td><td>${esc(r.image)}</td><td class="digest">${esc(r.local_digest||'—')}</td><td class="digest">${esc(r.remote_digest||'—')}</td><td>${r.error?`<span class="red" title="${esc(r.error)}">check failed</span>`:r.update?'<span class="amber">Update available</span>':'<span class="green">Current</span>'}</td></tr>`).join('')}</tbody></table>`}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
|
||||
async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','<input class="search" id="actFilter" placeholder="Filter action…"><button class="btn" id="actRefresh">↻ Refresh</button>')}<div class="panel" id="activityPanel"><div class="empty">Loading audit events…</div></div>`;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?`<table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>Status</th><th>Details</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td><b>${esc(x.actor)}</b></td><td><span class="tag">${esc(x.action)}</span></td><td>${esc(x.resource||'—')}</td><td>${x.status>=200&&x.status<300?'<span class="green">'+x.status+'</span>':'<span class="red">'+x.status+'</span>'}</td><td class="muted">${esc(JSON.stringify(x.detail||{}))}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No matching audit events.</div>'}catch(e){$('#activityPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}};$('#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()?'<button class="btn primary" id="addGit">+ Add Git source</button>':'')}<div class="panel" id="gitPanel"><div class="empty">Loading Git sources…</div></div>`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Stack</th><th>Environment</th><th>Repository</th><th>Branch</th><th>Commit</th><th>Last sync</th><th>Auto deploy</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.stack_name)}</b>${x.last_error?`<div class="red">${esc(x.last_error)}</div>`:''}</td><td>${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}</td><td>${esc(x.repo_url)}</td><td><span class="tag">${esc(x.branch)}</span></td><td class="digest">${esc((x.last_commit||'—').slice(0,12))}</td><td>${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}</td><td>${x.auto_deploy?'<span class="green">Yes</span>':'No'}</td><td class="right">${roleOK()?`<button class="btn tiny" data-gsync="${x.id}">Sync</button> <button class="btn tiny" data-gedit="${x.id}">Edit</button> <button class="btn tiny danger" data-gdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No Git sources configured.</div>';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=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function gitModal(x=null){modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} Git source</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Stack name</label><input id="gitStack" value="${esc(x?.stack_name||'')}"></div><div class="field"><label>Environment</label><select id="gitNode"><option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Branch</label><input id="gitBranch" value="${esc(x?.branch||'main')}"></div><div class="field full"><label>Repository URL</label><input id="gitRepo" value="${esc(x?.repo_url||'')}" placeholder="https://github.com/org/repo.git or ssh://..."></div><div class="field"><label>Workdir</label><input id="gitWorkdir" value="${esc(x?.workdir||'.')}"></div><div class="field"><label>Compose file</label><input id="gitCompose" value="${esc(x?.compose_file||'compose.yaml')}"></div><label class="switch"><input id="gitAuto" type="checkbox" ${x?.auto_deploy?'checked':''}> Deploy after sync/webhook</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveGit">Save</button></div>`);$('#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(`<div class="modalhead"><h2>Webhook created</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Copy this secret now. It is stored encrypted and will not be shown again.</div><div class="field" style="margin-top:10px"><label>Webhook URL</label><input readonly value="${esc(r.webhook_url)}"></div><div class="field" style="margin-top:10px"><label>Webhook secret</label><input readonly value="${esc(r.webhook_secret)}"></div><p class="muted">GitHub: use the secret for X-Hub-Signature-256. GitLab: send it as X-Gitlab-Token. Generic hooks may use X-Webhook-Token.</p></div><div class="modalfoot"><button class="btn primary" data-close>Done</button></div>`)}
|
||||
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'?'<button class="btn primary" id="addNotify">+ Add provider</button>':'')}<div class="panel" id="notifyPanel"><div class="empty">Loading providers…</div></div>`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Provider</th><th>Enabled</th><th>Configuration</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b></td><td><span class="tag">${esc(x.type)}</span></td><td>${x.enabled?'<span class="green">Enabled</span>':'Disabled'}</td><td class="muted">${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-ntest="${x.id}">Test</button> <button class="btn tiny" data-nedit="${x.id}">Edit</button> <button class="btn tiny danger" data-ndel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No notification providers configured.</div>';$$('[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=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Topic','topic')+f('nToken','Access token','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App token','token',true);return f('nHost','SMTP host','host')+f('nPort','Port','port',false,'587')+`<div class="field"><label>Security</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">None</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP authentication</label>`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> Disable TLS certificate verification (unsafe)</label>`}
|
||||
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} notification provider</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Type</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Enabled</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="nSave">Save</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
|
||||
|
||||
window.addEventListener('resize',terminalResize);
|
||||
function modal(html){$('#modalRoot').innerHTML=`<div class="modalback"><div class="modal" role="dialog" aria-modal="true">${html}</div></div>`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)}
|
||||
function closeModal(){$('#modalRoot').innerHTML=''}
|
||||
init().catch(e=>toast(e.message));
|
||||
@@ -0,0 +1,6 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed index.html app.js styles.css
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Dockwatch</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><div class="brandmark">DW</div><div class="brandcopy"><strong>Dockwatch</strong><small>Control Plane</small></div><button class="collapseBtn" id="sidebarToggle" title="Sidebar ein-/ausklappen" aria-label="Sidebar ein-/ausklappen">‹</button></div>
|
||||
<div class="environment"><span class="pulse"></span><div><small>Environment</small><select id="globalNode"></select></div></div>
|
||||
<nav id="nav">
|
||||
<button data-view="dashboard" class="active"><span>⌂</span>Dashboard</button>
|
||||
<div class="navlabel">Docker</div>
|
||||
<button data-view="stacks"><span>▱</span>Compose Stacks <em id="navStackCount"></em></button>
|
||||
<button data-view="containers"><span>⬡</span>Containers</button>
|
||||
<button data-view="images"><span>◫</span>Images</button>
|
||||
<button data-view="volumes"><span>◉</span>Volumes</button>
|
||||
<button data-view="networks"><span>⌘</span>Networks</button>
|
||||
<button data-view="git"><span>⑂</span>Git Stacks</button>
|
||||
<div class="navlabel">Observability</div>
|
||||
<button data-view="monitors"><span>♡</span>Probes <em id="navMonitorCount"></em></button>
|
||||
<button data-view="services"><span>◈</span>Services</button>
|
||||
<button data-view="statuspages"><span>◌</span>Status Pages</button>
|
||||
<button data-view="maintenance"><span>◷</span>Maintenance</button>
|
||||
<button data-view="notifications"><span>⌁</span>Notifications</button>
|
||||
<div class="navlabel">System</div>
|
||||
<button data-view="nodes"><span>◎</span>Environments</button>
|
||||
<button data-view="activity"><span>≋</span>Activity</button>
|
||||
</nav>
|
||||
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Logout">↪</button></div>
|
||||
</aside>
|
||||
<main>
|
||||
<header class="topbar"><div class="topLeft"><button class="iconbtn mobileMenu" id="mobileMenu" aria-label="Navigation öffnen">☰</button><span class="crumb" id="crumb">Dashboard</span></div><div class="topactions"><span id="apiState" class="apiState"><i></i><span>API</span></span><span id="dirtyTop" class="dirty" hidden>● Ungespeichert</span><button class="iconbtn" id="themeToggle" title="Theme wechseln" aria-label="Theme wechseln">◐</button><button class="iconbtn" id="refreshNow" title="Aktualisieren" aria-label="Aktualisieren">↻</button></div></header>
|
||||
<section id="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="modalRoot"></div><div id="toast"></div>
|
||||
<script src="/app.js"></script>
|
||||
</body></html>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user