17 Commits

Author SHA1 Message Date
lerko d8a2cab90f feat: seed SSH users from env var and authorized_keys file (#31)
CI / test (push) Successful in 2m36s
CI / lint (push) Successful in 1m12s
CI / vulncheck (push) Successful in 56s
Release / release (push) Has been cancelled
Release / docker (push) Has been cancelled
## Summary

Docker onboarding was broken — no way to add first SSH user without `docker attach` to TUI.

Now reads SSH public keys from two sources on startup:
- `UPTOP_ADMIN_KEY` env var — single key for quick single-user setup
- `UPTOP_KEYS` file path — authorized_keys format for team setup

Dockerfile already sets `UPTOP_KEYS=/data/authorized_keys` and compose mounts `./data:/data`, so the flow is:

```
echo "ssh-ed25519 AAAA... me@host" > ./data/authorized_keys
docker compose up -d
ssh -p 23234 localhost
```

### Behavior
- Skips keys already in DB (idempotent across restarts)
- All seeded users get admin role
- Username parsed from key comment (e.g. `tyler@macbook` → `tyler`)
- Comments and blank lines in keys file are ignored

### Tested
- UPTOP_ADMIN_KEY seeds single admin user
- UPTOP_KEYS file seeds multiple users with correct usernames
- Second startup skips existing keys (no duplicates)
- Build and all tests pass

Reviewed-on: lerko/uptop#31
2026-05-27 21:15:00 +00:00
lerko ea721601ab Merge pull request 'ci: overhaul pipeline — caching, GoReleaser, govulncheck' (#30) from ci/pipeline-overhaul into main
CI / test (push) Successful in 2m50s
CI / lint (push) Successful in 1m11s
CI / vulncheck (push) Successful in 56s
Reviewed-on: lerko/uptop#30
2026-05-27 00:37:32 +00:00
lerko b1935aa682 fix(deps): bump golang.org/x/crypto v0.47.0 → v0.52.0
CI / test (pull_request) Successful in 2m46s
CI / lint (pull_request) Successful in 1m12s
CI / vulncheck (pull_request) Successful in 56s
Fixes 7 vulns (GO-2026-5014 through GO-2026-5023) found by govulncheck.
Also bumps x/net, x/sys, x/text, x/sync, x/mod, x/tools to latest.
2026-05-26 20:20:23 -04:00
lerko 2cd3dcddb4 chore: bump Go 1.24.4 → 1.26.3, Alpine 3.21 → 3.23
CI / test (pull_request) Successful in 2m57s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Failing after 1m1s
Go 1.24 EOL since Feb 2026. Fixes 33 stdlib vulns found by
govulncheck (database/sql, os/exec, net/http). Gets Green Tea GC.
2026-05-26 20:12:43 -04:00
lerko 7d4ef1f594 fix(ci): remove explicit container, use sh shell
CI / test (pull_request) Successful in 2m44s
CI / lint (pull_request) Successful in 1m11s
CI / vulncheck (pull_request) Failing after 1m7s
Act runner is Alpine-based — container: directive breaks node-based
actions (checkout, setup-go). Runner already has apk natively.
Added shell: sh to all jobs since runner lacks bash.
2026-05-26 18:44:08 -04:00
lerko f0ff87c0d0 fix(ci): rename GITEA_TOKEN to RELEASE_TOKEN
CI / test (pull_request) Failing after 31s
CI / lint (pull_request) Successful in 1m9s
CI / vulncheck (pull_request) Failing after 15s
Gitea reserves the GITEA_ prefix for repo action secrets.
2026-05-26 18:36:11 -04:00
lerko 5aab391b74 ci: overhaul pipeline — caching, GoReleaser, govulncheck
- Add module + build cache to CI (was only caching go-build, not go/pkg/mod)
- Declare explicit Alpine container instead of relying on runner image
- Drop redundant go vet (already in golangci-lint)
- Add govulncheck job for dependency CVE scanning
- Add GoReleaser config for Gitea-native binary releases + checksums
- Replace .github/workflows/docker.yml with .gitea/workflows/release.yml
- Docker multiarch (amd64+arm64) via buildx in release workflow
- Dockerfile: add --mount=type=cache for mod/build, add -trimpath
2026-05-26 18:24:19 -04:00
lerko 8ad213c96c Merge pull request 'fix(security): phase 4 code quality and low-severity fixes' (#29) from security/phase-4-quality into main
CI / test (push) Successful in 4m35s
CI / lint (push) Successful in 1m7s
Reviewed-on: lerko/uptop#29
2026-05-26 21:31:40 +00:00
lerko 986f9f1d55 fix(security): phase 4 code quality and low-severity fixes
CI / test (pull_request) Successful in 4m24s
CI / lint (pull_request) Successful in 1m1s
- Fix limitStr to handle multi-byte UTF-8 characters correctly
- Sanitize log messages: strip ANSI escape sequences and newlines
- URL-encode probe node_id instead of string concatenation
- Fix follower resp.Body leak on non-200 responses
- Make SSH host key path configurable via UPTOP_SSH_HOST_KEY env var
- Add HTTP method checks on GET-only endpoints (405 for wrong methods)
- Extract magic numbers into named constants across monitor/store/server
- Standardize error output to stderr for all startup errors
2026-05-26 17:25:47 -04:00
lerko c50ec82dcb Merge pull request 'fix(security): phase 3 medium reliability and hardening' (#28) from security/phase-3-reliability into main
CI / test (push) Successful in 4m25s
CI / lint (push) Successful in 1m6s
Reviewed-on: lerko/uptop#28
2026-05-26 21:07:30 +00:00
lerko bd561d9a5e fix(security): phase 3 medium reliability and hardening
CI / test (pull_request) Successful in 4m23s
CI / lint (pull_request) Successful in 1m11s
- Fail hard on critical migration errors (ignore only "already exists")
- Cache SSH user keys with 30s TTL (avoid DB query per auth attempt)
- Configure DB connection pooling (25 open, 5 idle, 5m lifetime)
- Enable SQLite WAL mode for concurrent read/write
- Optimize check history pruning (only prune above 1100 rows)
- Add security headers: X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy
- Add CORS policy on /status/json via UPTOP_CORS_ORIGIN env var
- Add HTTP request logging middleware (method, path, status, duration, IP)
- Fix config file permissions from 0644 to 0600
- Pin Docker images: golang:1.24-alpine3.21, alpine:3.21
- Fix Docker CI tag pattern for CalVer (was semver)
- Pass build args (VERSION, COMMIT, BUILD_DATE) to Docker build
2026-05-26 16:57:03 -04:00
lerko 7a8f2ad15b Merge pull request 'fix(security): phase 2 high-severity hardening' (#27) from security/phase-2-hardening into main
CI / test (push) Successful in 4m33s
CI / lint (push) Successful in 1m6s
Reviewed-on: lerko/uptop#27
2026-05-26 15:31:18 +00:00
lerko d30d1460bd fix(security): phase 2 high-severity hardening
CI / test (pull_request) Successful in 4m31s
CI / lint (pull_request) Successful in 56s
- Push heartbeat accepts Authorization: Bearer header (query string deprecated)
- Gotify alerts use X-Gotify-Key header instead of token in URL
- Per-IP rate limiting on all API endpoints (token-bucket)
- /metrics gated behind cluster secret (UPTOP_METRICS_PUBLIC=true to opt out)
- Config export redacts passwords/tokens by default (redact_secrets=false to override)
- Fix rewritePlaceholders for 100+ SQL parameters
- Fix AddSiteReturningID/AddAlertReturningID race with LastInsertId/RETURNING
- HTTP server timeouts: read 30s, write 60s, idle 120s
2026-05-25 21:15:33 -04:00
lerko b43dfae98f Merge pull request 'fix(security): phase 1 critical fixes for public release' (#26) from security/phase-1-critical into main
CI / test (push) Successful in 4m19s
CI / lint (push) Successful in 1m6s
Reviewed-on: lerko/uptop#26
2026-05-26 00:43:52 +00:00
lerko 60b30935b3 fix(security): phase 1 critical fixes for public release
CI / test (pull_request) Successful in 4m40s
CI / lint (pull_request) Successful in 1m2s
- Redact PostgreSQL DSN password from stdout/logs
- Harden .dockerignore to exclude .ssh/, .claude/, *.db, *.local files
- SSRF protection: block private/loopback/link-local IPs by default
  (UPTOP_ALLOW_PRIVATE_TARGETS=true to override for homelab use)
- Fix email header injection via CRLF in monitor names
- AES-256-GCM encryption for alert credentials at rest
  (UPTOP_ENCRYPTION_KEY env var, migrate-secrets subcommand)
- TLS support for HTTP server (UPTOP_TLS_CERT/UPTOP_TLS_KEY)
  with HSTS header when TLS enabled
2026-05-25 11:26:47 -04:00
lerko b70edaace5 Merge pull request 'chore: rename project from go-upkeep to uptop' (#25) from chore/rename-uptop into main
CI / test (push) Successful in 4m26s
CI / lint (push) Successful in 1m1s
Reviewed-on: lerko/uptime#25
2026-05-25 01:02:30 +00:00
lerko 9d12e3ecf1 chore: complete rename from go-upkeep to uptop
CI / test (pull_request) Successful in 4m26s
CI / lint (pull_request) Successful in 1m11s
- Module path: gitea.lerkolabs.com/lerko/uptop
- Binary: cmd/uptop/
- All imports updated to full module path
- Env vars: UPKEEP_* → UPTOP_*
- Prometheus metrics: upkeep_* → uptop_*
- Default DB: uptop.db
- Docker image: lerko/uptop
- All docs, compose files, CI updated

Only remaining "go-upkeep" reference is the fork attribution in README.
2026-05-24 20:20:35 -04:00
38 changed files with 1451 additions and 405 deletions
+12
View File
@@ -1,3 +1,15 @@
.git .git
tmp/ tmp/
vendor/ vendor/
# Security: keep sensitive/local files out of Docker build context
.ssh/
.claude/
.github/
.gitea/
CLAUDE.md
*.local.json
*.local.md
*.local
*.db
*.db-journal
+33 -7
View File
@@ -5,6 +5,9 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
env:
GO_VERSION: "1.26"
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -16,32 +19,55 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.24" go-version: "1.26"
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/go-build path: |
key: go-build-${{ hashFiles('**/*.go', 'go.sum') }} ~/go/pkg/mod
restore-keys: go-build- ~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: go-
- name: Install build tools - name: Install build tools
run: apk add --no-cache gcc musl-dev run: apk add --no-cache gcc musl-dev
- name: Vet - name: Download modules
run: go vet ./... run: go mod download
- name: Test - name: Test
run: CGO_ENABLED=1 go test -race -timeout 120s ./... run: CGO_ENABLED=1 go test -race -timeout 120s ./...
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: "1.24" go-version: "1.26"
- uses: golangci/golangci-lint-action@v7 - uses: golangci/golangci-lint-action@v7
with: with:
version: v2.11.2 version: v2.11.2
vulncheck:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck
run: govulncheck ./...
+68
View File
@@ -0,0 +1,68 @@
name: Release
on:
push:
tags:
- "[0-9]*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: release-go-${{ hashFiles('go.sum') }}
restore-keys: release-go-
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GORELEASER_FORCE_TOKEN: gitea
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
docker:
runs-on: ubuntu-latest
needs: [release]
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: |
lerkolabs/uptop:${{ github.ref_name }}
lerkolabs/uptop:latest
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
-45
View File
@@ -1,45 +0,0 @@
name: Publish Release
on:
push:
tags:
- 'v*'
jobs:
push_to_registry:
name: Build and Push Docker Image
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop
tags: |
# This turns git tag "v1.0.0" into docker tag "1.0.0"
type=semver,pattern={{version}}
# This updates the "latest" tag to this version
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+2 -5
View File
@@ -26,8 +26,8 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go # End of https://www.toptal.com/developers/gitignore/api/go
/goupkeep /uptop
upkeep.db uptop.db
.ssh .ssh
@@ -35,8 +35,5 @@ authorized_keys
tmp tmp
# Old repo
/go-upkeep/
*.local.json *.local.json
*.local.md *.local.md
+42
View File
@@ -0,0 +1,42 @@
version: 2
gitea_urls:
api: https://gitea.lerkolabs.com/api/v1
download: https://gitea.lerkolabs.com
release:
gitea:
owner: lerko
name: uptop
builds:
- main: ./cmd/uptop/main.go
binary: uptop
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
ldflags:
- -s -w
- -X main.version={{ .Version }}
- -X main.commit={{ .Commit }}
- -X main.date={{ .Date }}
flags:
- -trimpath
archives:
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^chore:"
- "^style:"
+13 -10
View File
@@ -1,31 +1,34 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:alpine AS builder FROM golang:1.26-alpine3.23 AS builder
RUN apk add --no-cache gcc musl-dev RUN apk add --no-cache gcc musl-dev
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . . COPY . .
ENV CGO_ENABLED=1 ENV CGO_ENABLED=1
ARG VERSION=dev ARG VERSION=dev
ARG COMMIT=none ARG COMMIT=none
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown
RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o go-upkeep ./cmd/goupkeep/main.go RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go
# --- Stage 2: Runner --- # --- Stage 2: Runner ---
FROM alpine:latest FROM alpine:3.23
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates openssh-client RUN apk add --no-cache ca-certificates openssh-client
RUN mkdir /data RUN mkdir /data
COPY --from=builder /app/go-upkeep . COPY --from=builder /app/uptop .
# Set Default Configuration via ENV # Set Default Configuration via ENV
# Docker users can override these in docker-compose.yml # Docker users can override these in docker-compose.yml
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
ENV UPKEEP_DB_TYPE=sqlite ENV UPTOP_DB_TYPE=sqlite
ENV UPKEEP_DB_DSN=/data/upkeep.db ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPKEEP_KEYS=/data/authorized_keys ENV UPTOP_KEYS=/data/authorized_keys
ENV UPKEEP_PORT=23234 ENV UPTOP_PORT=23234
EXPOSE 23234 EXPOSE 23234
CMD ["./go-upkeep"] CMD ["./uptop"]
+17 -17
View File
@@ -2,7 +2,7 @@
Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`. Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`.
Built on the foundation of [RDGames/uptop](https://github.com/RDGames/uptop). Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
## What it does ## What it does
@@ -33,7 +33,7 @@ go run cmd/uptop/main.go -demo
### From source ### From source
```bash ```bash
go install gitea.lerkolabs.com/lerko/uptime/cmd/uptop@latest go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
``` ```
### Docker ### Docker
@@ -45,7 +45,7 @@ docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
### Binary ### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases). Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
## Config as code ## Config as code
@@ -81,10 +81,10 @@ services:
- ./data:/data - ./data:/data
- ./ssh_keys:/app/.ssh - ./ssh_keys:/app/.ssh
environment: environment:
- UPKEEP_DB_TYPE=sqlite - UPTOP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db - UPTOP_DB_DSN=/data/uptop.db
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_CLUSTER_SECRET=change-me - UPTOP_CLUSTER_SECRET=change-me
``` ```
First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH. First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
@@ -93,16 +93,16 @@ First run: attach to the container (`docker attach uptop`), go to the Users tab,
| Variable | Default | What it does | | Variable | Default | What it does |
|---|---|---| |---|---|---|
| `UPKEEP_PORT` | `23234` | SSH server port | | `UPTOP_PORT` | `23234` | SSH server port |
| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) | | `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` | | `UPTOP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string | | `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page | | `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title | | `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` | | `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes | | `UPTOP_PEER_URL` | | Leader URL for follower nodes |
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth | | `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks | | `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
## Migrating from Uptime Kuma ## Migrating from Uptime Kuma
+289 -63
View File
@@ -1,10 +1,22 @@
package main package main
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/cluster" "gitea.lerkolabs.com/lerko/uptop/internal/cluster"
"gitea.lerkolabs.com/lerko/uptop/internal/config" "gitea.lerkolabs.com/lerko/uptop/internal/config"
"gitea.lerkolabs.com/lerko/uptop/internal/importer" "gitea.lerkolabs.com/lerko/uptop/internal/importer"
@@ -13,12 +25,6 @@ import (
"gitea.lerkolabs.com/lerko/uptop/internal/server" "gitea.lerkolabs.com/lerko/uptop/internal/server"
"gitea.lerkolabs.com/lerko/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/tui" "gitea.lerkolabs.com/lerko/uptop/internal/tui"
"log"
"os"
"os/signal"
"strconv"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/ssh" "github.com/charmbracelet/ssh"
@@ -47,6 +53,9 @@ func main() {
case "version", "--version", "-v": case "version", "--version", "-v":
printVersion() printVersion()
return return
case "migrate-secrets":
runMigrateSecrets(os.Args[2:])
return
} }
} }
runServe(os.Args[1:]) runServe(os.Args[1:])
@@ -67,23 +76,42 @@ func envOrDefault(key, fallback string) string {
return fallback return fallback
} }
func redactDSN(dsn string) string {
u, err := url.Parse(dsn)
if err != nil {
return "***"
}
u.User = nil
return u.String()
}
func openStore(dbType, dsn string) store.Store { func openStore(dbType, dsn string) store.Store {
var s store.Store var ss *store.SQLStore
var err error var err error
if dbType == "postgres" { if dbType == "postgres" {
s, err = store.NewPostgresStore(dsn) ss, err = store.NewPostgresStore(dsn)
} else { } else {
s, err = store.NewSQLiteStore(dsn) ss, err = store.NewSQLiteStore(dsn)
} }
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "database error: %v\n", err) fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if err := s.Init(); err != nil { if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(encKey)
if err != nil {
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1)
}
ss.SetEncryptor(enc)
} else {
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
}
if err := ss.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
return s return ss
} }
func runApply(args []string) { func runApply(args []string) {
@@ -91,8 +119,8 @@ func runApply(args []string) {
filePath := fs.String("f", "", "Path to YAML config file (required)") filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying") dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML") prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
if *filePath == "" { if *filePath == "" {
@@ -124,8 +152,8 @@ func runApply(args []string) {
func runExport(args []string) { func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError) fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)") outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type") dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN") dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn) s := openStore(*dbType, *dsn)
@@ -142,10 +170,60 @@ func runExport(args []string) {
} }
} }
func runMigrateSecrets(args []string) {
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
_ = fs.Parse(args)
encKey := os.Getenv("UPTOP_ENCRYPTION_KEY")
if encKey == "" {
fmt.Fprintln(os.Stderr, "error: UPTOP_ENCRYPTION_KEY must be set")
os.Exit(1)
}
enc, err := store.NewEncryptor(encKey)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
var ss *store.SQLStore
if *dbType == "postgres" {
ss, err = store.NewPostgresStore(*dsn)
} else {
ss, err = store.NewSQLiteStore(*dsn)
}
if err != nil {
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1)
}
if err := ss.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
alerts, err := ss.GetAllAlerts()
if err != nil {
fmt.Fprintf(os.Stderr, "error loading alerts: %v\n", err)
os.Exit(1)
}
ss.SetEncryptor(enc)
migrated := 0
for _, a := range alerts {
if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil {
fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err)
os.Exit(1)
}
migrated++
}
fmt.Printf("Migrated %d alert(s) to encrypted storage.\n", migrated)
}
func runServe(args []string) { func runServe(args []string) {
portVal := 23234 portVal := 23234
dbType := "sqlite" dbType := "sqlite"
dbDSN := "upkeep.db" dbDSN := "uptop.db"
httpPort := 8080 httpPort := 8080
enableStatus := false enableStatus := false
statusTitle := "System Status" statusTitle := "System Status"
@@ -153,50 +231,50 @@ func runServe(args []string) {
clusterPeer := "" clusterPeer := ""
clusterKey := "" clusterKey := ""
if v := os.Getenv("UPKEEP_PORT"); v != "" { if v := os.Getenv("UPTOP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil { if p, err := strconv.Atoi(v); err == nil {
portVal = p portVal = p
} }
} }
if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" { if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
dbType = v dbType = v
} }
if v := os.Getenv("UPKEEP_DB_DSN"); v != "" { if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
dbDSN = v dbDSN = v
} }
if v := os.Getenv("UPKEEP_HTTP_PORT"); v != "" { if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil { if p, err := strconv.Atoi(v); err == nil {
httpPort = p httpPort = p
} }
} }
if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" { if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
enableStatus = true enableStatus = true
} }
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" { if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
statusTitle = v statusTitle = v
} }
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" { if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
clusterMode = v clusterMode = v
} }
if v := os.Getenv("UPKEEP_PEER_URL"); v != "" { if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
clusterPeer = v clusterPeer = v
} }
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" { if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
clusterKey = v clusterKey = v
} }
nodeID := os.Getenv("UPKEEP_NODE_ID") nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPKEEP_NODE_NAME") nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPKEEP_NODE_REGION") nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY") aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
if clusterMode == "probe" { if clusterMode == "probe" {
if nodeID == "" { if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
os.Exit(1) os.Exit(1)
} }
if clusterPeer == "" { if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode") fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
os.Exit(1) os.Exit(1)
} }
@@ -211,13 +289,19 @@ func runServe(args []string) {
cancel() cancel()
}() }()
probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
if probeAllowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
}
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{ if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
NodeID: nodeID, NodeID: nodeID,
NodeName: nodeName, NodeName: nodeName,
Region: nodeRegion, Region: nodeRegion,
LeaderURL: clusterPeer, LeaderURL: clusterPeer,
SharedKey: clusterKey, SharedKey: clusterKey,
Interval: 30, Interval: 30,
AllowPrivateTargets: probeAllowPrivate,
}); err != nil { }); err != nil {
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err) fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
} }
@@ -232,45 +316,64 @@ func runServe(args []string) {
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file") importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning _ = fs.Parse(args) // ExitOnError: parse errors exit before returning
var s store.Store var ss *store.SQLStore
var dbErr error var dbErr error
if *flagDBType == "postgres" { if *flagDBType == "postgres" {
s, dbErr = store.NewPostgresStore(*flagDSN) ss, dbErr = store.NewPostgresStore(*flagDSN)
fmt.Printf("Using PostgreSQL: %s\n", *flagDSN) fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
} else { } else {
s, dbErr = store.NewSQLiteStore(*flagDSN) ss, dbErr = store.NewSQLiteStore(*flagDSN)
fmt.Printf("Using SQLite: %s\n", *flagDSN) fmt.Printf("Using SQLite: %s\n", *flagDSN)
} }
if dbErr != nil { if dbErr != nil {
fmt.Printf("Database connection error: %v\n", dbErr) fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
os.Exit(1) os.Exit(1)
} }
defer s.Close() defer ss.Close()
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
enc, err := store.NewEncryptor(encKey)
if err != nil {
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
os.Exit(1)
}
ss.SetEncryptor(enc)
} else {
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
}
var s store.Store = ss
if err := s.Init(); err != nil { if err := s.Init(); err != nil {
fmt.Printf("Database init error: %v\n", err) fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if *demo { if *demo {
seedDemoData(s) seedDemoData(s)
} }
seedKeysFromEnv(s)
if *importKuma != "" { if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma) kb, err := importer.LoadKumaFile(*importKuma)
if err != nil { if err != nil {
fmt.Printf("Kuma import error: %v\n", err) fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
backup := importer.ConvertKuma(kb) backup := importer.ConvertKuma(kb)
if err := s.ImportData(backup); err != nil { if err := s.ImportData(backup); err != nil {
fmt.Printf("Import failed: %v\n", err) fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
} }
eng := monitor.NewEngine(s) allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" { if allowPrivate {
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
}
eng := monitor.NewEngineWithOpts(s, allowPrivate)
if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true) eng.SetInsecureSkipVerify(true)
} }
if aggStrategy != "" { if aggStrategy != "" {
@@ -284,11 +387,19 @@ func runServe(args []string) {
eng.InitLogs() eng.InitLogs()
eng.Start(ctx) eng.Start(ctx)
tlsCert := os.Getenv("UPTOP_TLS_CERT")
tlsKey := os.Getenv("UPTOP_TLS_KEY")
httpSrv := server.Start(server.ServerConfig{ httpSrv := server.Start(server.ServerConfig{
Port: httpPort, Port: httpPort,
EnableStatus: enableStatus, EnableStatus: enableStatus,
Title: statusTitle, Title: statusTitle,
ClusterKey: clusterKey, ClusterKey: clusterKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
ClusterMode: clusterMode,
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
}, s, eng) }, s, eng)
cluster.Start(ctx, cluster.Config{ cluster.Start(ctx, cluster.Config{
@@ -297,12 +408,13 @@ func runServe(args []string) {
SharedKey: clusterKey, SharedKey: clusterKey,
}, eng) }, eng)
sshSrv := startSSHServer(*port, s, eng) kc := newKeyCache(s)
sshSrv := startSSHServer(*port, s, eng, kc)
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion()) p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
} }
} else { } else {
fmt.Println("uptop running in HEADLESS mode") fmt.Println("uptop running in HEADLESS mode")
@@ -327,12 +439,12 @@ func runServe(args []string) {
} }
} }
func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server { func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) *ssh.Server {
s, err := wish.NewServer( s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf(":%d", port)), wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(".ssh/id_ed25519"), wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return isKeyAllowed(db, key) return kc.IsAllowed(key)
}), }),
wish.WithMiddleware( wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) { bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -341,7 +453,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server {
), ),
) )
if err != nil { if err != nil {
fmt.Printf("SSH server error: %v\n", err) fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
return nil return nil
} }
go func() { go func() {
@@ -401,19 +513,133 @@ func seedDemoData(s store.Store) {
} }
} }
func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { type keyCache struct {
users, err := db.GetAllUsers() mu sync.RWMutex
keys []ssh.PublicKey
updated time.Time
ttl time.Duration
db store.Store
}
func newKeyCache(db store.Store) *keyCache {
return &keyCache{db: db, ttl: 30 * time.Second}
}
func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers()
if err != nil { if err != nil {
return false return
} }
keys := make([]ssh.PublicKey, 0, len(users))
for _, u := range users { for _, u := range users {
allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
if err != nil { if err != nil {
continue continue
} }
if ssh.KeysEqual(allowedKey, incomingKey) { keys = append(keys, k)
}
c.mu.Lock()
c.keys = keys
c.updated = time.Now()
c.mu.Unlock()
}
func (c *keyCache) Invalidate() {
c.mu.Lock()
c.updated = time.Time{}
c.mu.Unlock()
}
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
c.mu.RLock()
stale := time.Since(c.updated) > c.ttl
c.mu.RUnlock()
if stale {
c.refresh()
}
c.mu.RLock()
defer c.mu.RUnlock()
for _, k := range c.keys {
if ssh.KeysEqual(k, incomingKey) {
return true return true
} }
} }
return false return false
} }
func seedKeysFromEnv(s store.Store) {
var keys []string
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
keys = append(keys, strings.TrimSpace(v))
}
if path := os.Getenv("UPTOP_KEYS"); path != "" {
f, err := os.Open(filepath.Clean(path))
if err == nil {
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
keys = append(keys, line)
}
_ = f.Close()
}
}
if len(keys) == 0 {
return
}
existing, err := s.GetAllUsers()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: could not check existing users: %v\n", err)
return
}
existingKeys := make(map[string]bool)
for _, u := range existing {
existingKeys[u.PublicKey] = true
}
added := 0
for i, key := range keys {
if existingKeys[key] {
continue
}
username := usernameFromKey(key, i, len(existing)+added)
if err := s.AddUser(username, key, "admin"); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to seed user %q: %v\n", username, err)
continue
}
fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != ""))
added++
}
}
func usernameFromKey(key string, index, totalExisting int) string {
parts := strings.Fields(key)
if len(parts) >= 3 {
comment := parts[2]
if at := strings.Index(comment, "@"); at > 0 {
return comment[:at]
}
return comment
}
if index == 0 && totalExisting == 0 {
return "admin"
}
return fmt.Sprintf("user-%d", totalExisting+1)
}
func seedSource(index, total int, hasEnvKey bool) string {
if hasEnvKey && index == 0 {
return "UPTOP_ADMIN_KEY"
}
return "UPTOP_KEYS"
}
+21 -21
View File
@@ -4,21 +4,21 @@ services:
# ------------------------- # -------------------------
leader: leader:
build: . build: .
container_name: upkeep-leader container_name: uptop-leader
ports: ports:
- "23234:23234" # SSH - "23234:23234" # SSH
- "8080:8080" # HTTP - "8080:8080" # HTTP
environment: environment:
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
# Note: Port 5432 is correct here because we are talking INSIDE the network # Note: Port 5432 is correct here because we are talking INSIDE the network
- UPKEEP_DB_DSN=postgres://devuser:devpass@leader-db:5432/upkeep_dev?sslmode=disable - UPTOP_DB_DSN=postgres://devuser:devpass@leader-db:5432/uptop_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Leader Node - UPTOP_STATUS_TITLE=Leader Node
# Cluster Config # Cluster Config
- UPKEEP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=mysecret - UPTOP_CLUSTER_SECRET=mysecret
depends_on: depends_on:
- leader-db - leader-db
stdin_open: true stdin_open: true
@@ -26,11 +26,11 @@ services:
leader-db: leader-db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-leader-db container_name: uptop-leader-db
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
volumes: volumes:
- ./tmp/leader-data:/var/lib/postgresql/data - ./tmp/leader-data:/var/lib/postgresql/data
@@ -39,23 +39,23 @@ services:
# ------------------------- # -------------------------
follower: follower:
build: . build: .
container_name: upkeep-follower container_name: uptop-follower
ports: ports:
- "23233:23234" # SSH (Mapped to different host port) - "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (Mapped to different host port) - "8081:8080" # HTTP (Mapped to different host port)
environment: environment:
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
# Connects to its OWN database # Connects to its OWN database
- UPKEEP_DB_DSN=postgres://devuser:devpass@follower-db:5432/upkeep_dev?sslmode=disable - UPTOP_DB_DSN=postgres://devuser:devpass@follower-db:5432/uptop_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Follower Node - UPTOP_STATUS_TITLE=Follower Node
# Cluster Config # Cluster Config
- UPKEEP_CLUSTER_MODE=follower - UPTOP_CLUSTER_MODE=follower
- UPKEEP_CLUSTER_SECRET=mysecret - UPTOP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally # IMPORTANT: Uses the Service Name "leader" to connect internally
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
depends_on: depends_on:
- follower-db - follower-db
stdin_open: true stdin_open: true
@@ -63,10 +63,10 @@ services:
follower-db: follower-db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-follower-db container_name: uptop-follower-db
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
volumes: volumes:
- ./tmp/follower-data:/var/lib/postgresql/data - ./tmp/follower-data:/var/lib/postgresql/data
+8 -8
View File
@@ -4,19 +4,19 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: upkeep-dev container_name: uptop-dev
ports: ports:
- "23234:23234" # SSH Access - "23234:23234" # SSH Access
- "8080:8080" # HTTP (Push Monitors + Status Page) - "8080:8080" # HTTP (Push Monitors + Status Page)
environment: environment:
# --- Database Configuration (Postgres) --- # --- Database Configuration (Postgres) ---
- UPKEEP_DB_TYPE=postgres - UPTOP_DB_TYPE=postgres
- UPKEEP_DB_DSN=postgres://devuser:devpass@postgres:5432/upkeep_dev?sslmode=disable - UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
# --- Web Server Configuration (Phase 4) --- # --- Web Server Configuration (Phase 4) ---
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Dev Infrastructure Status - UPTOP_STATUS_TITLE=Dev Infrastructure Status
depends_on: depends_on:
- postgres - postgres
stdin_open: true # Required for 'docker attach' (Local Admin Console) stdin_open: true # Required for 'docker attach' (Local Admin Console)
@@ -25,11 +25,11 @@ services:
# The Database # The Database
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: upkeep-postgres container_name: uptop-postgres
environment: environment:
POSTGRES_USER: devuser POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass POSTGRES_PASSWORD: devpass
POSTGRES_DB: upkeep_dev POSTGRES_DB: uptop_dev
ports: ports:
- "5432:5432" # Expose for external DB tools (DBeaver, etc.) - "5432:5432" # Expose for external DB tools (DBeaver, etc.)
volumes: volumes:
+16 -16
View File
@@ -2,10 +2,10 @@ services:
leader: leader:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=leader - UPTOP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
- UPKEEP_AGG_STRATEGY=any-down - UPTOP_AGG_STRATEGY=any-down
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
ports: ports:
- "8080:8080" - "8080:8080"
- "23234:23234" - "23234:23234"
@@ -13,23 +13,23 @@ services:
probe-us-east: probe-us-east:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=us-east-1 - UPTOP_NODE_ID=us-east-1
- UPKEEP_NODE_NAME=US East Probe - UPTOP_NODE_NAME=US East Probe
- UPKEEP_NODE_REGION=us-east - UPTOP_NODE_REGION=us-east
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
probe-eu-west: probe-eu-west:
build: . build: .
environment: environment:
- UPKEEP_CLUSTER_MODE=probe - UPTOP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=eu-west-1 - UPTOP_NODE_ID=eu-west-1
- UPKEEP_NODE_NAME=EU West Probe - UPTOP_NODE_NAME=EU West Probe
- UPKEEP_NODE_REGION=eu-west - UPTOP_NODE_REGION=eu-west
- UPKEEP_PEER_URL=http://leader:8080 - UPTOP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme - UPTOP_CLUSTER_SECRET=changeme
depends_on: depends_on:
- leader - leader
+8 -6
View File
@@ -3,16 +3,18 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: upkeep container_name: uptop
restart: unless-stopped restart: unless-stopped
ports: ports:
- "23234:23234" - "23234:23234"
- "8080:8080" - "8080:8080"
environment: environment:
- UPKEEP_DB_TYPE=sqlite - UPTOP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db - UPTOP_DB_DSN=/data/uptop.db
- UPKEEP_HTTP_PORT=8080 - UPTOP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true - UPTOP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=System Status - UPTOP_STATUS_TITLE=System Status
# SSH access: add your public key via env var or authorized_keys file
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
volumes: volumes:
- ./data:/data - ./data:/data
+2 -2
View File
@@ -207,11 +207,11 @@ Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:** **Pointing at a different database:**
```bash ```bash
uptop export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable" uptop export -db-type postgres -dsn "host=localhost dbname=uptop sslmode=disable"
uptop apply -f monitors.yaml -db-type postgres -dsn "..." uptop apply -f monitors.yaml -db-type postgres -dsn "..."
``` ```
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too. Both commands respect the `UPTOP_DB_TYPE` and `UPTOP_DB_DSN` environment variables too.
## How apply works ## How apply works
+9 -9
View File
@@ -1,6 +1,6 @@
module gitea.lerkolabs.com/lerko/uptop module gitea.lerkolabs.com/lerko/uptop
go 1.24.4 go 1.26.3
require ( require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
@@ -16,6 +16,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
github.com/miekg/dns v1.1.72 github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus-community/pro-bing v0.8.0
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
@@ -49,13 +50,12 @@ require (
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.47.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.31.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.40.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.33.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.40.0 // indirect golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+17 -16
View File
@@ -101,26 +101,27 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+25 -6
View File
@@ -5,12 +5,13 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http" "net/http"
"net/smtp" "net/smtp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
var alertClient = &http.Client{Timeout: 10 * time.Second} var alertClient = &http.Client{Timeout: 10 * time.Second}
@@ -24,6 +25,7 @@ type PayloadFunc func(title, message string) ([]byte, error)
type HTTPProvider struct { type HTTPProvider struct {
URL string URL string
Payload PayloadFunc Payload PayloadFunc
Headers map[string]string
} }
func (h *HTTPProvider) Send(ctx context.Context, title, message string) error { func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
@@ -36,6 +38,9 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
return err return err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
for k, v := range h.Headers {
req.Header.Set(k, v)
}
resp, err := alertClient.Do(req) resp, err := alertClient.Do(req)
if err != nil { if err != nil {
return err return err
@@ -164,8 +169,9 @@ func GetProvider(cfg models.AlertConfig) Provider {
} }
serverURL := strings.TrimRight(cfg.Settings["url"], "/") serverURL := strings.TrimRight(cfg.Settings["url"], "/")
return &HTTPProvider{ return &HTTPProvider{
URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]), URL: serverURL + "/message",
Payload: gotifyPayload(priority), Payload: gotifyPayload(priority),
Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]},
} }
default: default:
return nil return nil
@@ -176,6 +182,12 @@ type EmailProvider struct {
Host, Port, User, Pass, To, From string Host, Port, User, Pass, To, From string
} }
func sanitizeHeader(s string) string {
s = strings.ReplaceAll(s, "\r", "")
s = strings.ReplaceAll(s, "\n", "")
return s
}
func (e *EmailProvider) Send(ctx context.Context, title, message string) error { func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -183,11 +195,18 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
default: default:
} }
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host) auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
msg := []byte("To: " + e.To + "\r\n" + to := sanitizeHeader(e.To)
"Subject: uptop: " + title + "\r\n" + from := sanitizeHeader(e.From)
subject := sanitizeHeader(title)
body := strings.ReplaceAll(message, "\r", "")
msg := []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: uptop: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n" +
"\r\n" + "\r\n" +
message + "\r\n") body + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg) return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
} }
type NtfyProvider struct { type NtfyProvider struct {
+19 -1
View File
@@ -3,10 +3,11 @@ package alert
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
func TestHTTPProviderDiscord(t *testing.T) { func TestHTTPProviderDiscord(t *testing.T) {
@@ -212,3 +213,20 @@ func TestGetProviderUnknown(t *testing.T) {
t.Error("expected nil for unknown provider type") t.Error("expected nil for unknown provider type")
} }
} }
func TestSanitizeHeader(t *testing.T) {
tests := []struct {
input, want string
}{
{"normal subject", "normal subject"},
{"inject\r\nBcc: evil@bad.com", "injectBcc: evil@bad.com"},
{"has\nnewline", "hasnewline"},
{"has\rcarriage", "hascarriage"},
}
for _, tt := range tests {
got := sanitizeHeader(tt.input)
if got != tt.want {
t.Errorf("sanitizeHeader(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
+4 -3
View File
@@ -3,10 +3,11 @@ package cluster
import ( import (
"context" "context"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
) )
type Config struct { type Config struct {
@@ -57,8 +58,8 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
resp, err := client.Do(req) resp, err := client.Do(req)
isLeaderHealthy := false isLeaderHealthy := false
if err == nil && resp.StatusCode == 200 { if err == nil {
isLeaderHealthy = true isLeaderHealthy = resp.StatusCode == 200
_ = resp.Body.Close() _ = resp.Body.Close()
} }
+5 -4
View File
@@ -3,14 +3,15 @@ package cluster
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
) )
// --- Mock Store (minimal, for monitor.NewEngine) --- // --- Mock Store (minimal, for monitor.NewEngine) ---
@@ -295,7 +296,7 @@ func TestProbeExecuteChecks(t *testing.T) {
strict := &http.Client{} strict := &http.Client{}
insecure := &http.Client{} insecure := &http.Client{}
results := probeExecuteChecks(context.Background(), sites, strict, insecure) results := probeExecuteChecks(context.Background(), sites, strict, insecure, true)
if len(results) != 2 { if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results)) t.Fatalf("expected 2 results, got %d", len(results))
@@ -329,7 +330,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL}) sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
} }
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}) results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
if len(results) != 20 { if len(results) != 20 {
t.Errorf("expected 20 results, got %d", len(results)) t.Errorf("expected 20 results, got %d", len(results))
} }
+25 -14
View File
@@ -6,21 +6,24 @@ import (
"crypto/tls" "crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"log" "log"
"net/http" "net/http"
"net/url"
"sync" "sync"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
) )
type ProbeConfig struct { type ProbeConfig struct {
NodeID string NodeID string
NodeName string NodeName string
Region string Region string
LeaderURL string LeaderURL string
SharedKey string SharedKey string
Interval int Interval int
AllowPrivateTargets bool
} }
func RunProbe(ctx context.Context, cfg ProbeConfig) error { func RunProbe(ctx context.Context, cfg ProbeConfig) error {
@@ -29,11 +32,18 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
} }
apiClient := &http.Client{Timeout: 10 * time.Second} apiClient := &http.Client{Timeout: 10 * time.Second}
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
strictClient := &http.Client{ strictClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: dial,
},
} }
insecureClient := &http.Client{ insecureClient := &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
DialContext: dial,
},
} }
if err := probeRegister(ctx, apiClient, cfg); err != nil { if err := probeRegister(ctx, apiClient, cfg); err != nil {
@@ -59,7 +69,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
continue continue
} }
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient) results := probeExecuteChecks(ctx, sites, strictClient, insecureClient, cfg.AllowPrivateTargets)
if len(results) > 0 { if len(results) > 0 {
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil { if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
@@ -93,7 +103,8 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
} }
func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) { func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) {
req, err := http.NewRequestWithContext(ctx, "GET", cfg.LeaderURL+"/api/probe/assignments?node_id="+cfg.NodeID, nil) assignURL := cfg.LeaderURL + "/api/probe/assignments?" + url.Values{"node_id": {cfg.NodeID}}.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", assignURL, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -121,7 +132,7 @@ type probeResultItem struct {
IsUp bool `json:"is_up"` IsUp bool `json:"is_up"`
} }
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client) []probeResultItem { func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem {
var mu sync.Mutex var mu sync.Mutex
var results []probeResultItem var results []probeResultItem
sem := make(chan struct{}, 10) sem := make(chan struct{}, 10)
@@ -140,7 +151,7 @@ loop:
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
cr := monitor.RunCheck(s, strict, insecure, false) cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
mu.Lock() mu.Lock()
results = append(results, probeResultItem{ results = append(results, probeResultItem{
SiteID: s.ID, SiteID: s.ID,
+4 -3
View File
@@ -2,11 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"os" "os"
"sort" "sort"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -142,7 +143,7 @@ func WriteFile(f *File, path string) error {
_, err = os.Stdout.Write(data) _, err = os.Stdout.Write(data)
return err return err
} }
return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable return os.WriteFile(path, data, 0600)
} }
func LoadFile(path string) (*File, error) { func LoadFile(path string) (*File, error) {
+20 -20
View File
@@ -16,74 +16,74 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
var b strings.Builder var b strings.Builder
writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).") writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Status == "UP" { if s.Status == "UP" {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.") writeHelp(&b, "uptop_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
for _, s := range sites { for _, s := range sites {
writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds()) writeGauge(&b, "uptop_monitor_latency_seconds", labels(s), s.Latency.Seconds())
} }
writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.") writeHelp(&b, "uptop_monitor_status_code", "gauge", "HTTP response status code of the last check.")
for _, s := range sites { for _, s := range sites {
if s.Type != "http" { if s.Type != "http" {
continue continue
} }
writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode)) writeGauge(&b, "uptop_monitor_status_code", labels(s), float64(s.StatusCode))
} }
writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.") writeHelp(&b, "uptop_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
for _, s := range sites { for _, s := range sites {
if s.LastCheck.IsZero() { if s.LastCheck.IsZero() {
continue continue
} }
writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix())) writeGauge(&b, "uptop_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
} }
writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).") writeHelp(&b, "uptop_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if s.Paused { if s.Paused {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_paused", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).") writeHelp(&b, "uptop_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
for _, s := range sites { for _, s := range sites {
val := 0 val := 0
if eng.GetDisplayStatus(s) == "MAINT" { if eng.GetDisplayStatus(s) == "MAINT" {
val = 1 val = 1
} }
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val)) writeGauge(&b, "uptop_monitor_maintenance", labels(s), float64(val))
} }
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.") writeHelp(&b, "uptop_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
for _, s := range sites { for _, s := range sites {
if !s.HasSSL || s.CertExpiry.IsZero() { if !s.HasSSL || s.CertExpiry.IsZero() {
continue continue
} }
writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix())) writeGauge(&b, "uptop_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
} }
writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.") writeHelp(&b, "uptop_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.") writeHelp(&b, "uptop_monitor_checks_up_total", "counter", "Total number of successful checks.")
for _, s := range sites { for _, s := range sites {
h, ok := eng.GetHistory(s.ID) h, ok := eng.GetHistory(s.ID)
if !ok { if !ok {
continue continue
} }
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks)) writeGauge(&b, "uptop_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks)) writeGauge(&b, "uptop_monitor_checks_up_total", labels(s), float64(h.UpChecks))
} }
writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.") writeHelp(&b, "uptop_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
for _, site := range sites { for _, site := range sites {
probeResults := eng.GetProbeResults(site.ID) probeResults := eng.GetProbeResults(site.ID)
for nodeID, result := range probeResults { for nodeID, result := range probeResults {
@@ -92,7 +92,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
val = 1 val = 1
} }
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID)) nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val)) writeGauge(&b, "uptop_probe_up", nodeLabels, float64(val))
} }
} }
+7 -7
View File
@@ -94,13 +94,13 @@ func TestMetricsHandler(t *testing.T) {
} }
expected := []string{ expected := []string{
"# HELP upkeep_monitor_up", "# HELP uptop_monitor_up",
"# TYPE upkeep_monitor_up gauge", "# TYPE uptop_monitor_up gauge",
`upkeep_monitor_up{id="1",name="Example",type="http"}`, `uptop_monitor_up{id="1",name="Example",type="http"}`,
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`, `uptop_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP upkeep_monitor_latency_seconds", "# HELP uptop_monitor_latency_seconds",
"# HELP upkeep_monitor_paused", "# HELP uptop_monitor_paused",
"# HELP upkeep_monitor_checks_total", "# HELP uptop_monitor_checks_total",
} }
for _, s := range expected { for _, s := range expected {
if !strings.Contains(body, s) { if !strings.Contains(body, s) {
+21 -2
View File
@@ -2,13 +2,14 @@ package monitor
import ( import (
"context" "context"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"github.com/miekg/dns" "github.com/miekg/dns"
probing "github.com/prometheus-community/pro-bing" probing "github.com/prometheus-community/pro-bing"
) )
@@ -22,7 +23,25 @@ type CheckResult struct {
CertExpiry time.Time CertExpiry time.Time
} }
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult { func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
private := len(allowPrivate) > 0 && allowPrivate[0]
if site.Type != "http" && site.Type != "dns" && !private {
host := site.Hostname
if host == "" {
host = site.URL
}
if host != "" {
if ips, err := net.LookupIP(host); err == nil {
for _, ip := range ips {
if isPrivateIP(ip) {
return CheckResult{SiteID: site.ID, Status: "DOWN"}
}
}
}
}
}
switch site.Type { switch site.Type {
case "http": case "http":
return runHTTPCheck(site, strict, insecure, globalInsecure) return runHTTPCheck(site, strict, insecure, globalInsecure)
+22 -3
View File
@@ -2,13 +2,14 @@ package monitor
import ( import (
"crypto/tls" "crypto/tls"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"testing" "testing"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
) )
func TestRunCheck_HTTP_Success(t *testing.T) { func TestRunCheck_HTTP_Success(t *testing.T) {
@@ -132,7 +133,7 @@ func TestRunCheck_Port_Open(t *testing.T) {
port, _ := strconv.Atoi(portStr) port, _ := strconv.Atoi(portStr)
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(site, nil, nil, false) result := RunCheck(site, nil, nil, false, true)
if result.Status != "UP" { if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status) t.Errorf("expected UP, got %s", result.Status)
@@ -152,13 +153,31 @@ func TestRunCheck_Port_Closed(t *testing.T) {
ln.Close() ln.Close()
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1} site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
result := RunCheck(site, nil, nil, false) result := RunCheck(site, nil, nil, false, true)
if result.Status != "DOWN" { if result.Status != "DOWN" {
t.Errorf("expected DOWN, got %s", result.Status) t.Errorf("expected DOWN, got %s", result.Status)
} }
} }
func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portStr)
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
result := RunCheck(site, nil, nil, false)
if result.Status != "DOWN" {
t.Errorf("expected DOWN when private targets blocked, got %s", result.Status)
}
}
func TestRunCheck_UnknownType(t *testing.T) { func TestRunCheck_UnknownType(t *testing.T) {
site := models.Site{ID: 1, Type: "invalid"} site := models.Site{ID: 1, Type: "invalid"}
result := RunCheck(site, nil, nil, false) result := RunCheck(site, nil, nil, false)
+64 -28
View File
@@ -4,13 +4,23 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"math/rand/v2"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/alert" "gitea.lerkolabs.com/lerko/uptop/internal/alert"
"gitea.lerkolabs.com/lerko/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store" "gitea.lerkolabs.com/lerko/uptop/internal/store"
"math/rand/v2" )
"net/http"
"sync" const (
"time" maxLogEntries = 100
pollInterval = 5 * time.Second
pushGracePeriod = 5 * time.Second
minCheckInterval = 5
) )
type Engine struct { type Engine struct {
@@ -32,26 +42,43 @@ type Engine struct {
probeResults map[int]map[string]NodeResult probeResults map[int]map[string]NodeResult
aggStrategy AggregationStrategy aggStrategy AggregationStrategy
db store.Store db store.Store
insecureSkipVerify bool insecureSkipVerify bool
strictClient *http.Client allowPrivateTargets bool
insecureClient *http.Client strictClient *http.Client
insecureClient *http.Client
} }
func NewEngine(s store.Store) *Engine { func NewEngine(s store.Store) *Engine {
return newEngine(s, false)
}
func NewEngineWithOpts(s store.Store, allowPrivateTargets bool) *Engine {
return newEngine(s, allowPrivateTargets)
}
func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
dial := SafeDialContext(allowPrivateTargets)
return &Engine{ return &Engine{
liveState: make(map[int]models.Site), liveState: make(map[int]models.Site),
histories: make(map[int]*SiteHistory), histories: make(map[int]*SiteHistory),
tokenIndex: make(map[string]int), tokenIndex: make(map[string]int),
probeResults: make(map[int]map[string]NodeResult), probeResults: make(map[int]map[string]NodeResult),
aggStrategy: AggAnyDown, aggStrategy: AggAnyDown,
isActive: true, isActive: true,
db: s, allowPrivateTargets: allowPrivateTargets,
db: s,
strictClient: &http.Client{ strictClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}, Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: dial,
},
}, },
insecureClient: &http.Client{ insecureClient: &http.Client{
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
DialContext: dial,
},
}, },
} }
} }
@@ -60,14 +87,23 @@ func (e *Engine) SetInsecureSkipVerify(skip bool) {
e.insecureSkipVerify = skip e.insecureSkipVerify = skip
} }
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
func sanitizeLog(s string) string {
s = ansiRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\r", "")
return s
}
func (e *Engine) AddLog(msg string) { func (e *Engine) AddLog(msg string) {
e.logMu.Lock() e.logMu.Lock()
defer e.logMu.Unlock() defer e.logMu.Unlock()
ts := time.Now().Format("15:04:05") ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, msg) entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
e.logStore = append([]string{entry}, e.logStore...) e.logStore = append([]string{entry}, e.logStore...)
if len(e.logStore) > 100 { if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:100] e.logStore = e.logStore[:maxLogEntries]
} }
go func() { _ = e.db.SaveLog(entry) }() go func() { _ = e.db.SaveLog(entry) }()
} }
@@ -192,7 +228,7 @@ func (e *Engine) Start(ctx context.Context) {
if err != nil { if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err)) e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select { select {
case <-time.After(5 * time.Second): case <-time.After(pollInterval):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -226,7 +262,7 @@ func (e *Engine) Start(ctx context.Context) {
} }
select { select {
case <-time.After(5 * time.Second): case <-time.After(pollInterval):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -296,7 +332,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if !e.IsActive() { if !e.IsActive() {
select { select {
case <-time.After(5 * time.Second): case <-time.After(pollInterval):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -312,7 +348,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if site.Paused { if site.Paused {
select { select {
case <-time.After(5 * time.Second): case <-time.After(pollInterval):
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@@ -320,8 +356,8 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
} }
interval := site.Interval interval := site.Interval
if interval < 5 { if interval < minCheckInterval {
interval = 5 interval = minCheckInterval
} }
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select { select {
@@ -351,7 +387,7 @@ func (e *Engine) checkByID(id int) {
case "group": case "group":
e.checkGroup(site) e.checkGroup(site)
default: default:
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify) result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
updatedSite := site updatedSite := site
updatedSite.HasSSL = result.HasSSL updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry updatedSite.CertExpiry = result.CertExpiry
@@ -362,7 +398,7 @@ func (e *Engine) checkByID(id int) {
} }
func (e *Engine) checkPush(site models.Site) { func (e *Engine) checkPush(site models.Site) {
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second) deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(pushGracePeriod)
if time.Now().After(deadline) { if time.Now().After(deadline) {
e.handleStatusChange(site, "DOWN", 0, 0) e.handleStatusChange(site, "DOWN", 0, 0)
} else if site.Status != "UP" { } else if site.Status != "UP" {
+68
View File
@@ -0,0 +1,68 @@
package monitor
import (
"context"
"fmt"
"net"
"time"
)
var privateRanges []*net.IPNet
func init() {
cidrs := []string{
"127.0.0.0/8",
"::1/128",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"fe80::/10",
"fc00::/7",
}
for _, cidr := range cidrs {
_, network, _ := net.ParseCIDR(cidr)
privateRanges = append(privateRanges, network)
}
}
func isPrivateIP(ip net.IP) bool {
for _, network := range privateRanges {
if network.Contains(ip) {
return true
}
}
return false
}
func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
if !allowPrivate {
for _, ip := range ips {
if isPrivateIP(ip.IP) {
return nil, fmt.Errorf("blocked: %s resolves to private address %s", host, ip.IP)
}
}
}
dialer := &net.Dialer{Timeout: 10 * time.Second}
for _, ip := range ips {
target := net.JoinHostPort(ip.IP.String(), port)
conn, err := dialer.DialContext(ctx, network, target)
if err == nil {
return conn, nil
}
}
return nil, fmt.Errorf("failed to connect to %s", addr)
}
}
+47
View File
@@ -0,0 +1,47 @@
package monitor
import (
"net"
"testing"
)
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
ip string
private bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"169.254.169.254", true},
{"::1", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"93.184.216.34", false},
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := isPrivateIP(ip)
if got != tt.private {
t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private)
}
}
}
func TestSafeDialContext_BlocksPrivate(t *testing.T) {
dial := SafeDialContext(false)
_, err := dial(t.Context(), "tcp", "127.0.0.1:80")
if err == nil {
t.Error("expected error dialing loopback with private blocking enabled")
}
}
func TestSafeDialContext_AllowsPrivate(t *testing.T) {
dial := SafeDialContext(true)
_, err := dial(t.Context(), "tcp", "127.0.0.1:80")
// Will fail to connect (nothing listening) but should NOT be blocked
if err != nil && err.Error() == "blocked: 127.0.0.1 resolves to private address 127.0.0.1" {
t.Error("should not block private IPs when allowPrivate is true")
}
}
+91
View File
@@ -0,0 +1,91 @@
package server
import (
"net"
"net/http"
"sync"
"time"
)
type visitor struct {
tokens float64
lastSeen time.Time
}
type RateLimiter struct {
mu sync.Mutex
visitors map[string]*visitor
rate float64
burst float64
}
func NewRateLimiter(requestsPerMinute int) *RateLimiter {
rl := &RateLimiter{
visitors: make(map[string]*visitor),
rate: float64(requestsPerMinute) / 60.0,
burst: float64(requestsPerMinute),
}
go rl.cleanup()
return rl
}
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
v, exists := rl.visitors[ip]
now := time.Now()
if !exists {
rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now}
return true
}
elapsed := now.Sub(v.lastSeen).Seconds()
v.tokens += elapsed * rl.rate
if v.tokens > rl.burst {
v.tokens = rl.burst
}
v.lastSeen = now
if v.tokens < 1 {
return false
}
v.tokens--
return true
}
func (rl *RateLimiter) cleanup() {
for {
time.Sleep(5 * time.Minute)
rl.mu.Lock()
cutoff := time.Now().Add(-10 * time.Minute)
for ip, v := range rl.visitors {
if v.lastSeen.Before(cutoff) {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return fwd
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow(clientIP(r)) {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next(w, r)
}
}
+175 -38
View File
@@ -4,23 +4,51 @@ import (
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
"sort" "sort"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
) )
const maxRequestBody = 1 << 20
func checkSecret(got, want string) bool { func checkSecret(got, want string) bool {
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
} }
func extractBearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
var sensitiveKeys = map[string]bool{
"pass": true, "password": true, "token": true,
"routing_key": true, "user": true, "username": true,
}
func redactSettings(settings map[string]string) map[string]string {
redacted := make(map[string]string, len(settings))
for k, v := range settings {
if sensitiveKeys[k] && v != "" {
redacted[k] = "***REDACTED***"
} else {
redacted[k] = v
}
}
return redacted
}
var statusTpl = template.Must(template.New("status").Parse(` var statusTpl = template.Must(template.New("status").Parse(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -153,21 +181,42 @@ var statusTpl = template.Must(template.New("status").Parse(`
</html>`)) </html>`))
type ServerConfig struct { type ServerConfig struct {
Port int Port int
EnableStatus bool EnableStatus bool
Title string Title string
ClusterKey string // Shared Secret for Security ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
} }
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server { func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" { if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.") fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
} }
pushRL := NewRateLimiter(60)
probeRL := NewRateLimiter(30)
backupRL := NewRateLimiter(10)
statusRL := NewRateLimiter(120)
mux := http.NewServeMux() mux := http.NewServeMux()
// 1. Push Heartbeat // 1. Push Heartbeat
mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token") if r.Method != http.MethodGet && r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
token := extractBearerToken(r)
if token == "" {
if qt := r.URL.Query().Get("token"); qt != "" {
token = qt
log.Printf("DEPRECATED: push token in query string — use Authorization: Bearer header instead")
}
}
if token == "" { if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest) http.Error(w, "Missing token", http.StatusBadRequest)
return return
@@ -178,10 +227,14 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} else { } else {
http.Error(w, "Invalid Token", http.StatusNotFound) http.Error(w, "Invalid Token", http.StatusNotFound)
} }
}) }))
// 2. Health Check (For Cluster Follower) // 2. Health Check (For Cluster Follower)
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
@@ -191,9 +244,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
}) })
// 3. Config Export // 3. Config Export
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/export", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized) http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
return return
} }
data, err := s.ExportData() data, err := s.ExportData()
@@ -202,11 +255,16 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Export failed", http.StatusInternalServerError) http.Error(w, "Export failed", http.StatusInternalServerError)
return return
} }
if r.URL.Query().Get("redact_secrets") != "false" {
for i := range data.Alerts {
data.Alerts[i].Settings = redactSettings(data.Alerts[i].Settings)
}
}
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck _ = json.NewEncoder(w).Encode(data) //nolint:errcheck
}) }))
// 4. Config Import // 4. Config Import
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/backup/import", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@@ -215,7 +273,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var data models.Backup var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil { if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest) http.Error(w, "Invalid JSON", http.StatusBadRequest)
@@ -227,10 +285,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return return
} }
_, _ = w.Write([]byte("Import Successful")) _, _ = w.Write([]byte("Import Successful"))
}) }))
// 5. Kuma Import // 5. Kuma Import
mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/import/kuma", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@@ -239,7 +297,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var kb importer.KumaBackup var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil { if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err) log.Printf("Invalid Kuma JSON: %v", err)
@@ -253,10 +311,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return return
} }
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version) fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}) }))
// 6. Probe Registration // 6. Probe Registration
mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/register", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@@ -265,7 +323,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct { var req struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -288,10 +346,14 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return return
} }
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}) }))
// 7. Probe Assignment Fetch // 7. Probe Assignment Fetch
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/assignments", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) { if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
@@ -325,10 +387,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck _ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}) }))
// 8. Probe Result Submission // 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/probe/results", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed) http.Error(w, "POST required", http.StatusMethodNotAllowed)
return return
@@ -337,7 +399,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
var req struct { var req struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
Results []struct { Results []struct {
@@ -364,15 +426,27 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
log.Printf("Failed to update node last seen: %v", err) log.Printf("Failed to update node last seen: %v", err)
} }
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck _ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}) }))
// 9. Prometheus Metrics // 9. Prometheus Metrics
mux.HandleFunc("/metrics", metrics.Handler(eng)) mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if !cfg.MetricsPublic && cfg.ClusterKey != "" {
if !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
metrics.Handler(eng)(w, r)
})
// 10. Status Page // 10. Status Page
if cfg.EnableStatus { if cfg.EnableStatus {
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }) mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }))
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/status/json", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) {
state := eng.GetLiveState() state := eng.GetLiveState()
activeWindows, _ := s.GetActiveMaintenanceWindows() activeWindows, _ := s.GetActiveMaintenanceWindows()
maintSet := make(map[int]bool) maintSet := make(map[int]bool)
@@ -394,22 +468,85 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} }
state[id] = site state[id] = site
} }
if cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck _ = json.NewEncoder(w).Encode(state) //nolint:errcheck
}) }))
}
if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" {
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
}
handler := loggingMiddleware(securityHeadersMiddleware(mux))
if cfg.TLSCert != "" {
handler = hstsMiddleware(handler)
} }
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second} srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() { go func() {
fmt.Printf("HTTP Server listening on %s\n", addr) if cfg.TLSCert != "" && cfg.TLSKey != "" {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { fmt.Printf("HTTPS Server listening on %s\n", addr)
log.Printf("HTTP server error: %v", err) if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed {
log.Printf("HTTPS server error: %v", err)
}
} else {
fmt.Printf("HTTP Server listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("HTTP server error: %v", err)
}
} }
}() }()
return srv return srv
} }
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(sw, r)
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above
})
}
func securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
next.ServeHTTP(w, r)
})
}
func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) { func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
sites := eng.GetAllSites() sites := eng.GetAllSites()
+70
View File
@@ -0,0 +1,70 @@
package store
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"strings"
)
const encryptedPrefix = "enc:"
type Encryptor struct {
gcm cipher.AEAD
}
func NewEncryptor(hexKey string) (*Encryptor, error) {
key, err := hex.DecodeString(hexKey)
if err != nil {
return nil, fmt.Errorf("invalid encryption key: must be hex-encoded: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("invalid encryption key: must be 32 bytes (64 hex chars), got %d bytes", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("create GCM: %w", err)
}
return &Encryptor{gcm: gcm}, nil
}
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("generate nonce: %w", err)
}
ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
}
func (e *Encryptor) Decrypt(data string) (string, error) {
if !strings.HasPrefix(data, encryptedPrefix) {
return data, nil
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, encryptedPrefix))
if err != nil {
return "", fmt.Errorf("decode base64: %w", err)
}
nonceSize := e.gcm.NonceSize()
if len(raw) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
return string(plaintext), nil
}
func IsEncrypted(data string) bool {
return strings.HasPrefix(data, encryptedPrefix)
}
+83
View File
@@ -0,0 +1,83 @@
package store
import (
"encoding/hex"
"testing"
)
func testKey() string {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
return hex.EncodeToString(key)
}
func TestEncryptorRoundTrip(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
original := `{"host":"smtp.example.com","pass":"s3cret"}`
encrypted, err := enc.Encrypt(original)
if err != nil {
t.Fatal(err)
}
if !IsEncrypted(encrypted) {
t.Error("expected encrypted prefix")
}
if encrypted == original {
t.Error("encrypted should differ from original")
}
decrypted, err := enc.Decrypt(encrypted)
if err != nil {
t.Fatal(err)
}
if decrypted != original {
t.Errorf("got %q, want %q", decrypted, original)
}
}
func TestEncryptorDecryptPlaintext(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
plain := `{"url":"https://hooks.slack.com/test"}`
result, err := enc.Decrypt(plain)
if err != nil {
t.Fatal(err)
}
if result != plain {
t.Errorf("plaintext passthrough failed: got %q", result)
}
}
func TestEncryptorBadKey(t *testing.T) {
_, err := NewEncryptor("tooshort")
if err == nil {
t.Error("expected error for short key")
}
_, err = NewEncryptor("not-hex-at-all-but-long-enough-to-be-64-chars-if-we-keep-going!!")
if err == nil {
t.Error("expected error for non-hex key")
}
}
func TestEncryptorUniqueCiphertexts(t *testing.T) {
enc, err := NewEncryptor(testKey())
if err != nil {
t.Fatal(err)
}
a, _ := enc.Encrypt("same")
b, _ := enc.Encrypt("same")
if a == b {
t.Error("two encryptions of same plaintext should produce different ciphertexts")
}
}
+5 -7
View File
@@ -1,6 +1,9 @@
package store package store
import "database/sql" import (
"database/sql"
"strconv"
)
type Dialect interface { type Dialect interface {
DriverName() string DriverName() string
@@ -13,8 +16,6 @@ type Dialect interface {
UpsertNodeSQL() string UpsertNodeSQL() string
} }
// rewritePlaceholders converts ? markers to $1, $2, etc. for Postgres.
// For SQLite (or any dialect not needing rewrite), returns the input unchanged.
func rewritePlaceholders(query string, dollarStyle bool) string { func rewritePlaceholders(query string, dollarStyle bool) string {
if !dollarStyle { if !dollarStyle {
return query return query
@@ -25,10 +26,7 @@ func rewritePlaceholders(query string, dollarStyle bool) string {
if query[i] == '?' { if query[i] == '?' {
n++ n++
buf = append(buf, '$') buf = append(buf, '$')
if n >= 10 { buf = append(buf, []byte(strconv.Itoa(n))...)
buf = append(buf, byte('0'+n/10))
}
buf = append(buf, byte('0'+n%10))
} else { } else {
buf = append(buf, query[i]) buf = append(buf, query[i])
} }
+8 -1
View File
@@ -10,7 +10,14 @@ import (
type SQLiteDialect struct{} type SQLiteDialect struct{}
func NewSQLiteStore(path string) (*SQLStore, error) { func NewSQLiteStore(path string) (*SQLStore, error) {
return NewSQLStore("sqlite3", path, &SQLiteDialect{}) s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
if err != nil {
return nil, err
}
if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
log.Printf("WAL mode failed: %v", err)
}
return s, nil
} }
func (d *SQLiteDialect) DriverName() string { return "sqlite3" } func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
+121 -36
View File
@@ -6,15 +6,24 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models" "strings"
"log"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
const (
maxCheckHistory = 1000
checkHistoryPruneAt = 1100
maxMaintenanceExport = 1000
maxRequestBody = 1 << 20
) )
type SQLStore struct { type SQLStore struct {
db *sql.DB db *sql.DB
dialect Dialect dialect Dialect
dollar bool dollar bool
encryptor *Encryptor
} }
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) { func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
@@ -22,10 +31,31 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
_, isDollar := dialect.(*PostgresDialect) _, isDollar := dialect.(*PostgresDialect)
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
} }
func (s *SQLStore) SetEncryptor(enc *Encryptor) {
s.encryptor = enc
}
func (s *SQLStore) encryptSettings(jsonStr string) (string, error) {
if s.encryptor == nil {
return jsonStr, nil
}
return s.encryptor.Encrypt(jsonStr)
}
func (s *SQLStore) decryptSettings(data string) (string, error) {
if s.encryptor == nil {
return data, nil
}
return s.encryptor.Decrypt(data)
}
func (s *SQLStore) q(query string) string { func (s *SQLStore) q(query string) string {
return rewritePlaceholders(query, s.dollar) return rewritePlaceholders(query, s.dollar)
} }
@@ -50,7 +80,11 @@ func (s *SQLStore) Init() error {
} }
for _, m := range s.dialect.MigrationsSQL() { for _, m := range s.dialect.MigrationsSQL() {
if _, err := s.db.Exec(m); err != nil { if _, err := s.db.Exec(m); err != nil {
log.Printf("migration error: %v", err) errMsg := err.Error()
if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
continue
}
return fmt.Errorf("migration failed: %w", err)
} }
} }
return nil return nil
@@ -140,39 +174,82 @@ func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
return st, err return st, err
} }
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
decrypted, err := s.decryptSettings(raw)
if err != nil {
return nil, fmt.Errorf("decrypt settings: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
return nil, fmt.Errorf("unmarshal settings: %w", err)
}
return m, nil
}
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
jsonBytes, err := json.Marshal(settings)
if err != nil {
return "", err
}
return s.encryptSettings(string(jsonBytes))
}
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) { func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
var a models.AlertConfig var a models.AlertConfig
var settingsJSON string var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil { if err != nil {
return a, err return a, err
} }
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { a.Settings, err = s.unmarshalSettings(settingsRaw)
return a, fmt.Errorf("unmarshal alert settings: %w", err) if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
} }
return a, nil return a, nil
} }
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) { func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
if err := s.AddSite(site); err != nil { token := ""
return 0, err if site.Type == "push" {
var err error
token, err = generateToken()
if err != nil {
return 0, fmt.Errorf("generate push token: %w", err)
}
} }
created, err := s.GetSiteByName(site.Name) if s.dollar {
var id int
err := s.db.QueryRow(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id)
return id, err
}
result, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return created.ID, nil id, err := result.LastInsertId()
return int(id), err
} }
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) { func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
if err := s.AddAlert(name, aType, settings); err != nil { stored, err := s.marshalSettings(settings)
return 0, err
}
created, err := s.GetAlertByName(name)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return created.ID, nil if s.dollar {
var id int
err := s.db.QueryRow(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
return id, err
}
result, err := s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
} }
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) { func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
@@ -184,12 +261,13 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
var alerts []models.AlertConfig var alerts []models.AlertConfig
for rows.Next() { for rows.Next() {
var a models.AlertConfig var a models.AlertConfig
var settingsJSON string var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil { if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
return alerts, err return alerts, err
} }
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { a.Settings, err = s.unmarshalSettings(settingsRaw)
return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err) if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
} }
alerts = append(alerts, a) alerts = append(alerts, a)
} }
@@ -198,32 +276,33 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) { func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig var a models.AlertConfig
var settingsJSON string var settingsRaw string
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON) err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
if err != nil { if err != nil {
return a, err return a, err
} }
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil { a.Settings, err = s.unmarshalSettings(settingsRaw)
return a, fmt.Errorf("unmarshal alert settings: %w", err) if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
} }
return a, nil return a, nil
} }
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error { func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings) stored, err := s.marshalSettings(settings)
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes)) _, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
return err return err
} }
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error { func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
jsonBytes, err := json.Marshal(settings) stored, err := s.marshalSettings(settings)
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id) _, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
return err return err
} }
@@ -277,10 +356,16 @@ func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64,
if err != nil { if err != nil {
return err return err
} }
_, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN ( var count int
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000 _ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
)`), siteID, siteID) if count > checkHistoryPruneAt {
return err pruneQuery := fmt.Sprintf(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT %d
)`, maxCheckHistory)
_, err = s.db.Exec(s.q(pruneQuery), siteID, siteID)
return err
}
return nil
} }
func (s *SQLStore) RegisterNode(node models.ProbeNode) error { func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
@@ -493,7 +578,7 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
windows, err := s.GetAllMaintenanceWindows(1000) windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
if err != nil { if err != nil {
return models.Backup{}, err return models.Backup{}, err
} }
+2 -1
View File
@@ -2,12 +2,13 @@ package tui
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
+7 -5
View File
@@ -3,14 +3,15 @@ package tui
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"math" "math"
"sort" "sort"
"strings" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/harmonica" "github.com/charmbracelet/harmonica"
@@ -956,8 +957,9 @@ func siteOrder(s models.Site) int {
} }
func limitStr(text string, max int) string { func limitStr(text string, max int) string {
if len(text) > max { runes := []rune(text)
return text[:max-3] + "..." if len(runes) > max {
return string(runes[:max-3]) + "..."
} }
return text return text
} }