1 Commits

Author SHA1 Message Date
lerko d1bd66deb3 docs: add install instructions and Kuma migration guide to README
CI / test (pull_request) Successful in 4m37s
CI / lint (pull_request) Successful in 1m12s
2026-05-24 14:16:06 -04:00
53 changed files with 497 additions and 1772 deletions
-12
View File
@@ -1,15 +1,3 @@
.git
tmp/
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
+4 -36
View File
@@ -5,9 +5,6 @@ on:
branches: [main]
pull_request:
env:
GO_VERSION: "1.26"
jobs:
test:
runs-on: ubuntu-latest
@@ -19,55 +16,26 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- uses: actions/cache@v4
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: go-${{ hashFiles('go.sum') }}
restore-keys: go-
go-version: "1.24"
- name: Install build tools
run: apk add --no-cache gcc musl-dev
- name: Download modules
run: go mod download
- name: Vet
run: go vet ./...
- name: Test
run: CGO_ENABLED=1 go test -race -timeout 120s ./...
lint:
runs-on: ubuntu-latest
defaults:
run:
shell: sh
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
go-version: "1.24"
- uses: golangci/golangci-lint-action@v7
with:
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
@@ -1,68 +0,0 @@
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
@@ -0,0 +1,45 @@
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 }}/go-upkeep
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 }}
+5 -2
View File
@@ -26,8 +26,8 @@ go.work
# End of https://www.toptal.com/developers/gitignore/api/go
/uptop
uptop.db
/goupkeep
upkeep.db
.ssh
@@ -35,5 +35,8 @@ authorized_keys
tmp
# Old repo
/go-upkeep/
*.local.json
*.local.md
-42
View File
@@ -1,42 +0,0 @@
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:"
+1 -1
View File
@@ -3,7 +3,7 @@
## Development
```sh
go run cmd/uptop/main.go -demo # starts with sample data
go run cmd/goupkeep/main.go -demo # starts with sample data
ssh -p 23234 localhost # connect to TUI
```
+10 -13
View File
@@ -1,34 +1,31 @@
# --- Stage 1: Builder ---
FROM golang:1.26-alpine3.23 AS builder
FROM golang:alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN go mod download
COPY . .
ENV CGO_ENABLED=1
ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
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
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
# --- Stage 2: Runner ---
FROM alpine:3.23
FROM alpine:latest
WORKDIR /app
RUN apk add --no-cache ca-certificates openssh-client
RUN mkdir /data
COPY --from=builder /app/uptop .
COPY --from=builder /app/go-upkeep .
# Set Default Configuration via ENV
# Docker users can override these in docker-compose.yml
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
ENV UPTOP_DB_TYPE=sqlite
ENV UPTOP_DB_DSN=/data/uptop.db
ENV UPTOP_KEYS=/data/authorized_keys
ENV UPTOP_PORT=23234
ENV UPKEEP_DB_TYPE=sqlite
ENV UPKEEP_DB_DSN=/data/upkeep.db
ENV UPKEEP_KEYS=/data/authorized_keys
ENV UPKEEP_PORT=23234
EXPOSE 23234
CMD ["./uptop"]
CMD ["./go-upkeep"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Roman Dvořák
Copyright (c) 2024 Roman Dvořák
Copyright (c) 2026 Tyler Koenig
Permission is hereby granted, free of charge, to any person obtaining a copy
+26 -26
View File
@@ -1,4 +1,4 @@
# uptop
# Go-Upkeep
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`.
@@ -18,14 +18,14 @@ Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upk
## Quick start
```bash
go run cmd/uptop/main.go
go run cmd/goupkeep/main.go
ssh -p 23234 localhost
```
Seed some demo data to see it in action:
```bash
go run cmd/uptop/main.go -demo
go run cmd/goupkeep/main.go -demo
```
## Install
@@ -33,34 +33,34 @@ go run cmd/uptop/main.go -demo
### From source
```bash
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
go install gitea.lerkolabs.com/lerko/uptime/cmd/goupkeep@latest
```
### Docker
```bash
docker pull lerko/uptop:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
docker pull lerko/go-upkeep:latest
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/go-upkeep
```
### Binary
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases).
## Config as code
Export your current monitors:
```bash
uptop export -o monitors.yaml
goupkeep export -o monitors.yaml
```
Apply a config file:
```bash
uptop apply -f monitors.yaml
uptop apply -f monitors.yaml --dry-run # see what would change
uptop apply -f monitors.yaml --prune # delete anything not in the YAML
goupkeep apply -f monitors.yaml
goupkeep apply -f monitors.yaml --dry-run # see what would change
goupkeep apply -f monitors.yaml --prune # delete anything not in the YAML
```
See [docs/config-as-code.md](docs/config-as-code.md) for the full reference.
@@ -81,28 +81,28 @@ services:
- ./data:/data
- ./ssh_keys:/app/.ssh
environment:
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_STATUS_ENABLED=true
- UPTOP_CLUSTER_SECRET=change-me
- UPKEEP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_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 go-upkeep`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
## Environment variables
| Variable | Default | What it does |
|---|---|---|
| `UPTOP_PORT` | `23234` | SSH server port |
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPTOP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
| `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
| `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPTOP_PEER_URL` | | Leader URL for follower nodes |
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
| `UPKEEP_PORT` | `23234` | SSH server port |
| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string |
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page |
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title |
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes |
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
| `UPKEEP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
## Migrating from Uptime Kuma
+58 -284
View File
@@ -1,31 +1,25 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"go-upkeep/internal/cluster"
"go-upkeep/internal/config"
"go-upkeep/internal/importer"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/server"
"go-upkeep/internal/store"
"go-upkeep/internal/tui"
"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/config"
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"gitea.lerkolabs.com/lerko/uptop/internal/server"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
@@ -53,9 +47,6 @@ func main() {
case "version", "--version", "-v":
printVersion()
return
case "migrate-secrets":
runMigrateSecrets(os.Args[2:])
return
}
}
runServe(os.Args[1:])
@@ -63,9 +54,9 @@ func main() {
func printVersion() {
if version == "dev" {
fmt.Println("uptop dev")
fmt.Println("go-upkeep dev")
} else {
fmt.Printf("uptop %s (%s, %s)\n", version, commit, date)
fmt.Printf("go-upkeep %s (%s, %s)\n", version, commit, date)
}
}
@@ -76,42 +67,23 @@ func envOrDefault(key, fallback string) string {
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 {
var ss *store.SQLStore
var s store.Store
var err error
if dbType == "postgres" {
ss, err = store.NewPostgresStore(dsn)
s, err = store.NewPostgresStore(dsn)
} else {
ss, err = store.NewSQLiteStore(dsn)
s, err = store.NewSQLiteStore(dsn)
}
if err != nil {
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
os.Exit(1)
}
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 {
if err := s.Init(); err != nil {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
os.Exit(1)
}
return ss
return s
}
func runApply(args []string) {
@@ -119,8 +91,8 @@ func runApply(args []string) {
filePath := fs.String("f", "", "Path to YAML config file (required)")
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
if *filePath == "" {
@@ -152,8 +124,8 @@ func runApply(args []string) {
func runExport(args []string) {
fs := flag.NewFlagSet("export", flag.ExitOnError)
outPath := fs.String("o", "-", "Output file path (- for stdout)")
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
s := openStore(*dbType, *dsn)
@@ -170,60 +142,10 @@ 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) {
portVal := 23234
dbType := "sqlite"
dbDSN := "uptop.db"
dbDSN := "upkeep.db"
httpPort := 8080
enableStatus := false
statusTitle := "System Status"
@@ -231,50 +153,50 @@ func runServe(args []string) {
clusterPeer := ""
clusterKey := ""
if v := os.Getenv("UPTOP_PORT"); v != "" {
if v := os.Getenv("UPKEEP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
portVal = p
}
}
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" {
dbType = v
}
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
if v := os.Getenv("UPKEEP_DB_DSN"); v != "" {
dbDSN = v
}
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
if v := os.Getenv("UPKEEP_HTTP_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil {
httpPort = p
}
}
if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" {
enableStatus = true
}
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" {
statusTitle = v
}
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" {
clusterMode = v
}
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
if v := os.Getenv("UPKEEP_PEER_URL"); v != "" {
clusterPeer = v
}
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" {
clusterKey = v
}
nodeID := os.Getenv("UPTOP_NODE_ID")
nodeName := os.Getenv("UPTOP_NODE_NAME")
nodeRegion := os.Getenv("UPTOP_NODE_REGION")
aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
nodeID := os.Getenv("UPKEEP_NODE_ID")
nodeName := os.Getenv("UPKEEP_NODE_NAME")
nodeRegion := os.Getenv("UPKEEP_NODE_REGION")
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY")
if clusterMode == "probe" {
if nodeID == "" {
fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode")
os.Exit(1)
}
if clusterPeer == "" {
fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode")
os.Exit(1)
}
@@ -289,11 +211,6 @@ func runServe(args []string) {
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{
NodeID: nodeID,
NodeName: nodeName,
@@ -301,7 +218,6 @@ func runServe(args []string) {
LeaderURL: clusterPeer,
SharedKey: clusterKey,
Interval: 30,
AllowPrivateTargets: probeAllowPrivate,
}); err != nil {
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
}
@@ -316,64 +232,45 @@ func runServe(args []string) {
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
var ss *store.SQLStore
var s store.Store
var dbErr error
if *flagDBType == "postgres" {
ss, dbErr = store.NewPostgresStore(*flagDSN)
fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
s, dbErr = store.NewPostgresStore(*flagDSN)
fmt.Printf("Using PostgreSQL: %s\n", *flagDSN)
} else {
ss, dbErr = store.NewSQLiteStore(*flagDSN)
s, dbErr = store.NewSQLiteStore(*flagDSN)
fmt.Printf("Using SQLite: %s\n", *flagDSN)
}
if dbErr != nil {
fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
fmt.Printf("Database connection error: %v\n", dbErr)
os.Exit(1)
}
defer ss.Close()
defer s.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 {
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
fmt.Printf("Database init error: %v\n", err)
os.Exit(1)
}
if *demo {
seedDemoData(s)
}
seedKeysFromEnv(s)
if *importKuma != "" {
kb, err := importer.LoadKumaFile(*importKuma)
if err != nil {
fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
fmt.Printf("Kuma import error: %v\n", err)
os.Exit(1)
}
backup := importer.ConvertKuma(kb)
if err := s.ImportData(backup); err != nil {
fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
fmt.Printf("Import failed: %v\n", err)
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)
}
allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "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 := monitor.NewEngine(s)
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
eng.SetInsecureSkipVerify(true)
}
if aggStrategy != "" {
@@ -387,19 +284,11 @@ func runServe(args []string) {
eng.InitLogs()
eng.Start(ctx)
tlsCert := os.Getenv("UPTOP_TLS_CERT")
tlsKey := os.Getenv("UPTOP_TLS_KEY")
httpSrv := server.Start(server.ServerConfig{
Port: httpPort,
EnableStatus: enableStatus,
Title: statusTitle,
ClusterKey: clusterKey,
TLSCert: tlsCert,
TLSKey: tlsKey,
ClusterMode: clusterMode,
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
}, s, eng)
cluster.Start(ctx, cluster.Config{
@@ -408,16 +297,15 @@ func runServe(args []string) {
SharedKey: clusterKey,
}, eng)
kc := newKeyCache(s)
sshSrv := startSSHServer(*port, s, eng, kc)
sshSrv := startSSHServer(*port, s, eng)
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
fmt.Printf("Error: %v\n", err)
}
} else {
fmt.Println("uptop running in HEADLESS mode")
fmt.Println("Go-Upkeep running in HEADLESS mode")
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-done
@@ -439,12 +327,12 @@ func runServe(args []string) {
}
}
func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) *ssh.Server {
func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server {
s, err := wish.NewServer(
wish.WithAddress(fmt.Sprintf(":%d", port)),
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
wish.WithHostKeyPath(".ssh/id_ed25519"),
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
return kc.IsAllowed(key)
return isKeyAllowed(db, key)
}),
wish.WithMiddleware(
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
@@ -453,7 +341,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
),
)
if err != nil {
fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
fmt.Printf("SSH server error: %v\n", err)
return nil
}
go func() {
@@ -513,133 +401,19 @@ func seedDemoData(s store.Store) {
}
}
type keyCache struct {
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()
func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
users, err := db.GetAllUsers()
if err != nil {
return
return false
}
keys := make([]ssh.PublicKey, 0, len(users))
for _, u := range users {
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
if err != nil {
continue
}
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) {
if ssh.KeysEqual(allowedKey, incomingKey) {
return true
}
}
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:
build: .
container_name: uptop-leader
container_name: upkeep-leader
ports:
- "23234:23234" # SSH
- "8080:8080" # HTTP
environment:
- UPTOP_DB_TYPE=postgres
- UPKEEP_DB_TYPE=postgres
# Note: Port 5432 is correct here because we are talking INSIDE the network
- UPTOP_DB_DSN=postgres://devuser:devpass@leader-db:5432/uptop_dev?sslmode=disable
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Leader Node
- UPKEEP_DB_DSN=postgres://devuser:devpass@leader-db:5432/upkeep_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Leader Node
# Cluster Config
- UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=mysecret
- UPKEEP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=mysecret
depends_on:
- leader-db
stdin_open: true
@@ -26,11 +26,11 @@ services:
leader-db:
image: postgres:15-alpine
container_name: uptop-leader-db
container_name: upkeep-leader-db
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: uptop_dev
POSTGRES_DB: upkeep_dev
volumes:
- ./tmp/leader-data:/var/lib/postgresql/data
@@ -39,23 +39,23 @@ services:
# -------------------------
follower:
build: .
container_name: uptop-follower
container_name: upkeep-follower
ports:
- "23233:23234" # SSH (Mapped to different host port)
- "8081:8080" # HTTP (Mapped to different host port)
environment:
- UPTOP_DB_TYPE=postgres
- UPKEEP_DB_TYPE=postgres
# Connects to its OWN database
- UPTOP_DB_DSN=postgres://devuser:devpass@follower-db:5432/uptop_dev?sslmode=disable
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Follower Node
- UPKEEP_DB_DSN=postgres://devuser:devpass@follower-db:5432/upkeep_dev?sslmode=disable
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Follower Node
# Cluster Config
- UPTOP_CLUSTER_MODE=follower
- UPTOP_CLUSTER_SECRET=mysecret
- UPKEEP_CLUSTER_MODE=follower
- UPKEEP_CLUSTER_SECRET=mysecret
# IMPORTANT: Uses the Service Name "leader" to connect internally
- UPTOP_PEER_URL=http://leader:8080
- UPKEEP_PEER_URL=http://leader:8080
depends_on:
- follower-db
stdin_open: true
@@ -63,10 +63,10 @@ services:
follower-db:
image: postgres:15-alpine
container_name: uptop-follower-db
container_name: upkeep-follower-db
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: uptop_dev
POSTGRES_DB: upkeep_dev
volumes:
- ./tmp/follower-data:/var/lib/postgresql/data
+8 -8
View File
@@ -4,19 +4,19 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: uptop-dev
container_name: upkeep-dev
ports:
- "23234:23234" # SSH Access
- "8080:8080" # HTTP (Push Monitors + Status Page)
environment:
# --- Database Configuration (Postgres) ---
- UPTOP_DB_TYPE=postgres
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
- UPKEEP_DB_TYPE=postgres
- UPKEEP_DB_DSN=postgres://devuser:devpass@postgres:5432/upkeep_dev?sslmode=disable
# --- Web Server Configuration (Phase 4) ---
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=Dev Infrastructure Status
depends_on:
- postgres
stdin_open: true # Required for 'docker attach' (Local Admin Console)
@@ -25,11 +25,11 @@ services:
# The Database
postgres:
image: postgres:15-alpine
container_name: uptop-postgres
container_name: upkeep-postgres
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: uptop_dev
POSTGRES_DB: upkeep_dev
ports:
- "5432:5432" # Expose for external DB tools (DBeaver, etc.)
volumes:
+16 -16
View File
@@ -2,10 +2,10 @@ services:
leader:
build: .
environment:
- UPTOP_CLUSTER_MODE=leader
- UPTOP_CLUSTER_SECRET=changeme
- UPTOP_AGG_STRATEGY=any-down
- UPTOP_STATUS_ENABLED=true
- UPKEEP_CLUSTER_MODE=leader
- UPKEEP_CLUSTER_SECRET=changeme
- UPKEEP_AGG_STRATEGY=any-down
- UPKEEP_STATUS_ENABLED=true
ports:
- "8080:8080"
- "23234:23234"
@@ -13,23 +13,23 @@ services:
probe-us-east:
build: .
environment:
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=us-east-1
- UPTOP_NODE_NAME=US East Probe
- UPTOP_NODE_REGION=us-east
- UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=us-east-1
- UPKEEP_NODE_NAME=US East Probe
- UPKEEP_NODE_REGION=us-east
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
depends_on:
- leader
probe-eu-west:
build: .
environment:
- UPTOP_CLUSTER_MODE=probe
- UPTOP_NODE_ID=eu-west-1
- UPTOP_NODE_NAME=EU West Probe
- UPTOP_NODE_REGION=eu-west
- UPTOP_PEER_URL=http://leader:8080
- UPTOP_CLUSTER_SECRET=changeme
- UPKEEP_CLUSTER_MODE=probe
- UPKEEP_NODE_ID=eu-west-1
- UPKEEP_NODE_NAME=EU West Probe
- UPKEEP_NODE_REGION=eu-west
- UPKEEP_PEER_URL=http://leader:8080
- UPKEEP_CLUSTER_SECRET=changeme
depends_on:
- leader
+6 -8
View File
@@ -3,18 +3,16 @@ services:
build:
context: .
dockerfile: Dockerfile
container_name: uptop
container_name: upkeep
restart: unless-stopped
ports:
- "23234:23234"
- "8080:8080"
environment:
- UPTOP_DB_TYPE=sqlite
- UPTOP_DB_DSN=/data/uptop.db
- UPTOP_HTTP_PORT=8080
- UPTOP_STATUS_ENABLED=true
- 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
- UPKEEP_DB_TYPE=sqlite
- UPKEEP_DB_DSN=/data/upkeep.db
- UPKEEP_HTTP_PORT=8080
- UPKEEP_STATUS_ENABLED=true
- UPKEEP_STATUS_TITLE=System Status
volumes:
- ./data:/data
+13 -13
View File
@@ -7,13 +7,13 @@ Define your monitors and alerts in a YAML file. Version control them, copy them
Export what you already have:
```bash
uptop export -o monitors.yaml
goupkeep export -o monitors.yaml
```
That gives you a working file you can edit and re-apply:
```bash
uptop apply -f monitors.yaml
goupkeep apply -f monitors.yaml
```
That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
@@ -184,34 +184,34 @@ All 9 providers work in the YAML. The `settings` map is different per type.
**Export current state:**
```bash
uptop export -o monitors.yaml # to a file
uptop export # to stdout
goupkeep export -o monitors.yaml # to a file
goupkeep export # to stdout
```
**Apply a config:**
```bash
uptop apply -f monitors.yaml
goupkeep apply -f monitors.yaml
```
**See what would change first:**
```bash
uptop apply -f monitors.yaml --dry-run
goupkeep apply -f monitors.yaml --dry-run
```
**Delete monitors not in the YAML:**
```bash
uptop apply -f monitors.yaml --prune
goupkeep apply -f monitors.yaml --prune
```
Without `--prune`, apply never deletes anything. It only creates and updates.
**Pointing at a different database:**
```bash
uptop export -db-type postgres -dsn "host=localhost dbname=uptop sslmode=disable"
uptop apply -f monitors.yaml -db-type postgres -dsn "..."
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable"
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..."
```
Both commands respect the `UPTOP_DB_TYPE` and `UPTOP_DB_DSN` environment variables too.
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too.
## How apply works
@@ -230,15 +230,15 @@ If something fails mid-apply, just fix the issue and run it again. It picks up w
```bash
# set up your monitors in the TUI first, then export
uptop export -o monitors.yaml
goupkeep export -o monitors.yaml
# commit it
git add monitors.yaml && git commit -m "add monitor config"
# deploy to another instance
scp monitors.yaml prod-server:
ssh prod-server uptop apply -f monitors.yaml
ssh prod-server goupkeep apply -f monitors.yaml
# or just keep it as a backup you can restore from
uptop apply -f monitors.yaml
goupkeep apply -f monitors.yaml
```
+10 -10
View File
@@ -1,6 +1,6 @@
module gitea.lerkolabs.com/lerko/uptop
module go-upkeep
go 1.26.3
go 1.24.4
require (
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
@@ -16,7 +16,6 @@ require (
github.com/mattn/go-sqlite3 v1.14.33
github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -50,12 +49,13 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+16 -17
View File
@@ -101,27 +101,26 @@ 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/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=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
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/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+7 -26
View File
@@ -5,13 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"net/http"
"net/smtp"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
var alertClient = &http.Client{Timeout: 10 * time.Second}
@@ -25,7 +24,6 @@ type PayloadFunc func(title, message string) ([]byte, error)
type HTTPProvider struct {
URL string
Payload PayloadFunc
Headers map[string]string
}
func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
@@ -38,9 +36,6 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range h.Headers {
req.Header.Set(k, v)
}
resp, err := alertClient.Do(req)
if err != nil {
return err
@@ -81,7 +76,7 @@ func pagerdutyPayload(routingKey, severity string) PayloadFunc {
"event_action": "trigger",
"payload": map[string]string{
"summary": fmt.Sprintf("%s: %s", title, message),
"source": "uptop",
"source": "go-upkeep",
"severity": severity,
},
})
@@ -169,9 +164,8 @@ func GetProvider(cfg models.AlertConfig) Provider {
}
serverURL := strings.TrimRight(cfg.Settings["url"], "/")
return &HTTPProvider{
URL: serverURL + "/message",
URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]),
Payload: gotifyPayload(priority),
Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]},
}
default:
return nil
@@ -182,12 +176,6 @@ type EmailProvider struct {
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 {
select {
case <-ctx.Done():
@@ -195,18 +183,11 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
default:
}
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
to := sanitizeHeader(e.To)
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" +
msg := []byte("To: " + e.To + "\r\n" +
"Subject: Go-Upkeep: " + title + "\r\n" +
"\r\n" +
body + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
message + "\r\n")
return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg)
}
type NtfyProvider struct {
+1 -19
View File
@@ -3,11 +3,10 @@ package alert
import (
"context"
"encoding/json"
"go-upkeep/internal/models"
"net/http"
"net/http/httptest"
"testing"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
func TestHTTPProviderDiscord(t *testing.T) {
@@ -213,20 +212,3 @@ func TestGetProviderUnknown(t *testing.T) {
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)
}
}
}
+3 -4
View File
@@ -3,11 +3,10 @@ package cluster
import (
"context"
"fmt"
"go-upkeep/internal/monitor"
"net/http"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
)
type Config struct {
@@ -58,8 +57,8 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
resp, err := client.Do(req)
isLeaderHealthy := false
if err == nil {
isLeaderHealthy = resp.StatusCode == 200
if err == nil && resp.StatusCode == 200 {
isLeaderHealthy = true
_ = resp.Body.Close()
}
+4 -5
View File
@@ -3,15 +3,14 @@ package cluster
import (
"context"
"encoding/json"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
)
// --- Mock Store (minimal, for monitor.NewEngine) ---
@@ -296,7 +295,7 @@ func TestProbeExecuteChecks(t *testing.T) {
strict := &http.Client{}
insecure := &http.Client{}
results := probeExecuteChecks(context.Background(), sites, strict, insecure, true)
results := probeExecuteChecks(context.Background(), sites, strict, insecure)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
@@ -330,7 +329,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
}
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{})
if len(results) != 20 {
t.Errorf("expected 20 results, got %d", len(results))
}
+8 -19
View File
@@ -6,14 +6,12 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"log"
"net/http"
"net/url"
"sync"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
)
type ProbeConfig struct {
@@ -23,7 +21,6 @@ type ProbeConfig struct {
LeaderURL string
SharedKey string
Interval int
AllowPrivateTargets bool
}
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
@@ -32,18 +29,11 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
}
apiClient := &http.Client{Timeout: 10 * time.Second}
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
strictClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: dial,
},
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
}
insecureClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
DialContext: dial,
},
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
}
if err := probeRegister(ctx, apiClient, cfg); err != nil {
@@ -69,7 +59,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
continue
}
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient, cfg.AllowPrivateTargets)
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient)
if len(results) > 0 {
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
@@ -103,8 +93,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
}
func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) {
assignURL := cfg.LeaderURL + "/api/probe/assignments?" + url.Values{"node_id": {cfg.NodeID}}.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", assignURL, nil)
req, err := http.NewRequestWithContext(ctx, "GET", cfg.LeaderURL+"/api/probe/assignments?node_id="+cfg.NodeID, nil)
if err != nil {
return nil, err
}
@@ -132,7 +121,7 @@ type probeResultItem struct {
IsUp bool `json:"is_up"`
}
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem {
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client) []probeResultItem {
var mu sync.Mutex
var results []probeResultItem
sem := make(chan struct{}, 10)
@@ -151,7 +140,7 @@ loop:
defer wg.Done()
defer func() { <-sem }()
cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
cr := monitor.RunCheck(s, strict, insecure, false)
mu.Lock()
results = append(results, probeResultItem{
SiteID: s.ID,
+2 -2
View File
@@ -2,8 +2,8 @@ package config
import (
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"reflect"
"strings"
)
+2 -2
View File
@@ -1,8 +1,8 @@
package config
import (
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"strings"
"testing"
)
+3 -4
View File
@@ -2,12 +2,11 @@ package config
import (
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"os"
"sort"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"gopkg.in/yaml.v3"
)
@@ -143,7 +142,7 @@ func WriteFile(f *File, path string) error {
_, err = os.Stdout.Write(data)
return err
}
return os.WriteFile(path, data, 0600)
return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable
}
func LoadFile(path string) (*File, error) {
+1 -1
View File
@@ -1,7 +1,7 @@
package config
import (
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
"testing"
)
+1 -1
View File
@@ -3,7 +3,7 @@ package importer
import (
"encoding/json"
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
"os"
"strings"
)
+22 -22
View File
@@ -2,8 +2,8 @@ package metrics
import (
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"sort"
"strings"
@@ -16,74 +16,74 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
var b strings.Builder
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
for _, s := range sites {
val := 0
if s.Status == "UP" {
val = 1
}
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val))
}
writeHelp(&b, "uptop_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
for _, s := range sites {
writeGauge(&b, "uptop_monitor_latency_seconds", labels(s), s.Latency.Seconds())
writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds())
}
writeHelp(&b, "uptop_monitor_status_code", "gauge", "HTTP response status code of the last check.")
writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.")
for _, s := range sites {
if s.Type != "http" {
continue
}
writeGauge(&b, "uptop_monitor_status_code", labels(s), float64(s.StatusCode))
writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode))
}
writeHelp(&b, "uptop_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
for _, s := range sites {
if s.LastCheck.IsZero() {
continue
}
writeGauge(&b, "uptop_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
}
writeHelp(&b, "uptop_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
for _, s := range sites {
val := 0
if s.Paused {
val = 1
}
writeGauge(&b, "uptop_monitor_paused", labels(s), float64(val))
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
}
writeHelp(&b, "uptop_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
for _, s := range sites {
val := 0
if eng.GetDisplayStatus(s) == "MAINT" {
val = 1
}
writeGauge(&b, "uptop_monitor_maintenance", labels(s), float64(val))
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val))
}
writeHelp(&b, "uptop_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
for _, s := range sites {
if !s.HasSSL || s.CertExpiry.IsZero() {
continue
}
writeGauge(&b, "uptop_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
}
writeHelp(&b, "uptop_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "uptop_monitor_checks_up_total", "counter", "Total number of successful checks.")
writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.")
writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.")
for _, s := range sites {
h, ok := eng.GetHistory(s.ID)
if !ok {
continue
}
writeGauge(&b, "uptop_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "uptop_monitor_checks_up_total", labels(s), float64(h.UpChecks))
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks))
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks))
}
writeHelp(&b, "uptop_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
for _, site := range sites {
probeResults := eng.GetProbeResults(site.ID)
for nodeID, result := range probeResults {
@@ -92,7 +92,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
val = 1
}
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
writeGauge(&b, "uptop_probe_up", nodeLabels, float64(val))
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val))
}
}
+9 -9
View File
@@ -2,8 +2,8 @@ package metrics
import (
"context"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net/http"
"net/http/httptest"
"strings"
@@ -94,13 +94,13 @@ func TestMetricsHandler(t *testing.T) {
}
expected := []string{
"# HELP uptop_monitor_up",
"# TYPE uptop_monitor_up gauge",
`uptop_monitor_up{id="1",name="Example",type="http"}`,
`uptop_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP uptop_monitor_latency_seconds",
"# HELP uptop_monitor_paused",
"# HELP uptop_monitor_checks_total",
"# HELP upkeep_monitor_up",
"# TYPE upkeep_monitor_up gauge",
`upkeep_monitor_up{id="1",name="Example",type="http"}`,
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`,
"# HELP upkeep_monitor_latency_seconds",
"# HELP upkeep_monitor_paused",
"# HELP upkeep_monitor_checks_total",
}
for _, s := range expected {
if !strings.Contains(body, s) {
+2 -21
View File
@@ -2,14 +2,13 @@ package monitor
import (
"context"
"go-upkeep/internal/models"
"net"
"net/http"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"github.com/miekg/dns"
probing "github.com/prometheus-community/pro-bing"
)
@@ -23,25 +22,7 @@ type CheckResult struct {
CertExpiry time.Time
}
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"}
}
}
}
}
}
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool) CheckResult {
switch site.Type {
case "http":
return runHTTPCheck(site, strict, insecure, globalInsecure)
+3 -22
View File
@@ -2,14 +2,13 @@ package monitor
import (
"crypto/tls"
"go-upkeep/internal/models"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
func TestRunCheck_HTTP_Success(t *testing.T) {
@@ -133,7 +132,7 @@ func TestRunCheck_Port_Open(t *testing.T) {
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, true)
result := RunCheck(site, nil, nil, false)
if result.Status != "UP" {
t.Errorf("expected UP, got %s", result.Status)
@@ -153,28 +152,10 @@ func TestRunCheck_Port_Closed(t *testing.T) {
ln.Close()
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
result := RunCheck(site, nil, nil, false, true)
if result.Status != "DOWN" {
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)
t.Errorf("expected DOWN, got %s", result.Status)
}
}
+16 -52
View File
@@ -4,23 +4,13 @@ import (
"context"
"crypto/tls"
"fmt"
"go-upkeep/internal/alert"
"go-upkeep/internal/models"
"go-upkeep/internal/store"
"math/rand/v2"
"net/http"
"regexp"
"strings"
"sync"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
)
const (
maxLogEntries = 100
pollInterval = 5 * time.Second
pushGracePeriod = 5 * time.Second
minCheckInterval = 5
)
type Engine struct {
@@ -44,21 +34,11 @@ type Engine struct {
db store.Store
insecureSkipVerify bool
allowPrivateTargets bool
strictClient *http.Client
insecureClient *http.Client
}
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{
liveState: make(map[int]models.Site),
histories: make(map[int]*SiteHistory),
@@ -66,19 +46,12 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
probeResults: make(map[int]map[string]NodeResult),
aggStrategy: AggAnyDown,
isActive: true,
allowPrivateTargets: allowPrivateTargets,
db: s,
strictClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: dial,
},
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
},
insecureClient: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
DialContext: dial,
},
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
},
}
}
@@ -87,23 +60,14 @@ func (e *Engine) SetInsecureSkipVerify(skip bool) {
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) {
e.logMu.Lock()
defer e.logMu.Unlock()
ts := time.Now().Format("15:04:05")
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
entry := fmt.Sprintf("[%s] %s", ts, msg)
e.logStore = append([]string{entry}, e.logStore...)
if len(e.logStore) > maxLogEntries {
e.logStore = e.logStore[:maxLogEntries]
if len(e.logStore) > 100 {
e.logStore = e.logStore[:100]
}
go func() { _ = e.db.SaveLog(entry) }()
}
@@ -228,7 +192,7 @@ func (e *Engine) Start(ctx context.Context) {
if err != nil {
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
select {
case <-time.After(pollInterval):
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
@@ -262,7 +226,7 @@ func (e *Engine) Start(ctx context.Context) {
}
select {
case <-time.After(pollInterval):
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
@@ -332,7 +296,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if !e.IsActive() {
select {
case <-time.After(pollInterval):
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
@@ -348,7 +312,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
if site.Paused {
select {
case <-time.After(pollInterval):
case <-time.After(5 * time.Second):
case <-ctx.Done():
return
}
@@ -356,8 +320,8 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
}
interval := site.Interval
if interval < minCheckInterval {
interval = minCheckInterval
if interval < 5 {
interval = 5
}
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
select {
@@ -387,7 +351,7 @@ func (e *Engine) checkByID(id int) {
case "group":
e.checkGroup(site)
default:
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify)
updatedSite := site
updatedSite.HasSSL = result.HasSSL
updatedSite.CertExpiry = result.CertExpiry
@@ -398,7 +362,7 @@ func (e *Engine) checkByID(id int) {
}
func (e *Engine) checkPush(site models.Site) {
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(pushGracePeriod)
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(5 * time.Second)
if time.Now().After(deadline) {
e.handleStatusChange(site, "DOWN", 0, 0)
} else if site.Status != "UP" {
+1 -1
View File
@@ -2,7 +2,7 @@ package monitor
import (
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
"sync"
"testing"
"time"
-68
View File
@@ -1,68 +0,0 @@
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
@@ -1,47 +0,0 @@
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
@@ -1,91 +0,0 @@
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)
}
}
+33 -170
View File
@@ -4,51 +4,23 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"go-upkeep/internal/importer"
"go-upkeep/internal/metrics"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"html/template"
"log"
"net/http"
"sort"
"strings"
"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 {
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(`
<!DOCTYPE html>
<html>
@@ -87,7 +59,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
<div id="summary" class="summary"></div>
<div id="stale" class="stale-bar"></div>
<div id="cards"></div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by uptop</div>
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
</div>
<script>
var lastUpdate = null;
@@ -184,39 +156,18 @@ type ServerConfig struct {
Port int
EnableStatus bool
Title string
ClusterKey string
TLSCert string
TLSKey string
ClusterMode string
MetricsPublic bool
CORSOrigin string
ClusterKey string // Shared Secret for Security
}
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
if cfg.ClusterKey == "" {
fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
}
pushRL := NewRateLimiter(60)
probeRL := NewRateLimiter(30)
backupRL := NewRateLimiter(10)
statusRL := NewRateLimiter(120)
mux := http.NewServeMux()
// 1. Push Heartbeat
mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) {
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")
}
}
mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
http.Error(w, "Missing token", http.StatusBadRequest)
return
@@ -227,14 +178,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
} else {
http.Error(w, "Invalid Token", http.StatusNotFound)
}
}))
})
// 2. Health Check (For Cluster Follower)
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) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
@@ -244,9 +191,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
})
// 3. Config Export
mux.HandleFunc("/api/backup/export", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized)
return
}
data, err := s.ExportData()
@@ -255,16 +202,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Export failed", http.StatusInternalServerError)
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
}))
})
// 4. Config Import
mux.HandleFunc("/api/backup/import", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
@@ -273,7 +215,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var data models.Backup
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
@@ -285,10 +227,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return
}
_, _ = w.Write([]byte("Import Successful"))
}))
})
// 5. Kuma Import
mux.HandleFunc("/api/import/kuma", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
@@ -297,7 +239,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var kb importer.KumaBackup
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
log.Printf("Invalid Kuma JSON: %v", err)
@@ -311,10 +253,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return
}
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
}))
})
// 6. Probe Registration
mux.HandleFunc("/api/probe/register", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
@@ -323,7 +265,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -346,14 +288,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
return
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
})
// 7. Probe Assignment Fetch
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
}
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
@@ -387,10 +325,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
}))
})
// 8. Probe Result Submission
mux.HandleFunc("/api/probe/results", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
@@ -399,7 +337,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req struct {
NodeID string `json:"node_id"`
Results []struct {
@@ -426,27 +364,15 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
log.Printf("Failed to update node last seen: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
}))
})
// 9. Prometheus Metrics
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)
})
mux.HandleFunc("/metrics", metrics.Handler(eng))
// 10. Status Page
if cfg.EnableStatus {
mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }))
mux.HandleFunc("/status/json", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
state := eng.GetLiveState()
activeWindows, _ := s.GetActiveMaintenanceWindows()
maintSet := make(map[int]bool)
@@ -468,85 +394,22 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
}
state[id] = site
}
if cfg.CORSOrigin != "" {
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
}
w.Header().Set("Content-Type", "application/json")
_ = 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)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go func() {
if cfg.TLSCert != "" && cfg.TLSKey != "" {
fmt.Printf("HTTPS Server listening on %s\n", addr)
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
}
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) {
sites := eng.GetAllSites()
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"bytes"
"encoding/json"
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"net"
"net/http"
"sync"
-70
View File
@@ -1,70 +0,0 @@
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
@@ -1,83 +0,0 @@
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")
}
}
+7 -5
View File
@@ -1,9 +1,6 @@
package store
import (
"database/sql"
"strconv"
)
import "database/sql"
type Dialect interface {
DriverName() string
@@ -16,6 +13,8 @@ type Dialect interface {
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 {
if !dollarStyle {
return query
@@ -26,7 +25,10 @@ func rewritePlaceholders(query string, dollarStyle bool) string {
if query[i] == '?' {
n++
buf = append(buf, '$')
buf = append(buf, []byte(strconv.Itoa(n))...)
if n >= 10 {
buf = append(buf, byte('0'+n/10))
}
buf = append(buf, byte('0'+n%10))
} else {
buf = append(buf, query[i])
}
+1 -8
View File
@@ -10,14 +10,7 @@ import (
type SQLiteDialect struct{}
func NewSQLiteStore(path string) (*SQLStore, error) {
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
return NewSQLStore("sqlite3", path, &SQLiteDialect{})
}
func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
+32 -117
View File
@@ -6,24 +6,15 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"go-upkeep/internal/models"
"log"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
)
const (
maxCheckHistory = 1000
checkHistoryPruneAt = 1100
maxMaintenanceExport = 1000
maxRequestBody = 1 << 20
)
type SQLStore struct {
db *sql.DB
dialect Dialect
dollar bool
encryptor *Encryptor
}
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
@@ -31,31 +22,10 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
if err != nil {
return nil, err
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
_, isDollar := dialect.(*PostgresDialect)
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 {
return rewritePlaceholders(query, s.dollar)
}
@@ -80,11 +50,7 @@ func (s *SQLStore) Init() error {
}
for _, m := range s.dialect.MigrationsSQL() {
if _, err := s.db.Exec(m); err != nil {
errMsg := err.Error()
if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
continue
}
return fmt.Errorf("migration failed: %w", err)
log.Printf("migration error: %v", err)
}
}
return nil
@@ -174,82 +140,39 @@ func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
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) {
var a models.AlertConfig
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, &settingsRaw)
var settingsJSON 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)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %q: %w", name, err)
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
}
return a, nil
}
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
token := ""
if site.Type == "push" {
var err error
token, err = generateToken()
if err != nil {
return 0, fmt.Errorf("generate push token: %w", err)
if err := s.AddSite(site); err != nil {
return 0, err
}
}
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)
created, err := s.GetSiteByName(site.Name)
if err != nil {
return 0, err
}
id, err := result.LastInsertId()
return int(id), err
return created.ID, nil
}
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
stored, err := s.marshalSettings(settings)
if err := s.AddAlert(name, aType, settings); err != nil {
return 0, err
}
created, err := s.GetAlertByName(name)
if err != nil {
return 0, err
}
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
return created.ID, nil
}
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
@@ -261,13 +184,12 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
var alerts []models.AlertConfig
for rows.Next() {
var a models.AlertConfig
var settingsRaw string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
var settingsJSON string
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil {
return alerts, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err)
}
alerts = append(alerts, a)
}
@@ -276,33 +198,32 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
var a models.AlertConfig
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, &settingsRaw)
var settingsJSON 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)
if err != nil {
return a, err
}
a.Settings, err = s.unmarshalSettings(settingsRaw)
if err != nil {
return a, fmt.Errorf("alert %d: %w", id, err)
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
return a, fmt.Errorf("unmarshal alert settings: %w", err)
}
return a, nil
}
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
jsonBytes, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes))
return err
}
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
stored, err := s.marshalSettings(settings)
jsonBytes, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id)
return err
}
@@ -356,16 +277,10 @@ func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64,
if err != nil {
return err
}
var count int
_ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
if count > checkHistoryPruneAt {
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)
_, err = s.db.Exec(s.q(`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 1000
)`), siteID, siteID)
return err
}
return nil
}
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
@@ -578,7 +493,7 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
if err != nil {
return models.Backup{}, err
}
windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
windows, err := s.GetAllMaintenanceWindows(1000)
if err != nil {
return models.Backup{}, err
}
+1 -1
View File
@@ -1,7 +1,7 @@
package store
import (
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
"testing"
)
+1 -1
View File
@@ -1,7 +1,7 @@
package store
import (
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
)
type Store interface {
+1 -1
View File
@@ -319,7 +319,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
).Title("Gotify Settings").WithHideFunc(func() bool {
return m.alertFormData.AlertType != "gotify"
}),
).WithTheme(m.theme.HuhTheme())
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
}
+3 -3
View File
@@ -2,7 +2,7 @@ package tui
import (
"fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"go-upkeep/internal/models"
"strconv"
"time"
@@ -11,7 +11,7 @@ import (
"github.com/charmbracelet/lipgloss"
)
var maintStyle lipgloss.Style
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))
type maintFormData struct {
Title string
@@ -187,7 +187,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
).Title("Duration").WithHideFunc(func() bool {
return m.maintFormData.Type == "incident"
}),
).WithTheme(m.theme.HuhTheme())
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
}
+11 -18
View File
@@ -2,13 +2,12 @@ package tui
import (
"fmt"
"go-upkeep/internal/models"
"net/url"
"strconv"
"strings"
"time"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
@@ -38,7 +37,10 @@ func typeIcon(siteType string, collapsed bool) string {
}
}
var siteGroupStyle lipgloss.Style
var siteGroupStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
type siteFormData struct {
Name string
@@ -338,10 +340,10 @@ func (m Model) viewSitesTab() string {
if len(m.sites) == 0 {
welcome := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Accent).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, 3).
Render(
titleStyle.Render("uptop") + "\n\n" +
titleStyle.Render("Go-Upkeep") + "\n\n" +
"No monitors configured yet.\n\n" +
subtleStyle.Render("[n] Add your first monitor"),
)
@@ -507,7 +509,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Required for HTTP monitors").
Value(&m.siteFormData.URL).
Validate(func(s string) error {
if m.siteFormData.SiteType != "http" {
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
return nil
}
if s == "" {
@@ -553,15 +555,12 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Description("Target port for TCP port monitors").
Value(&m.siteFormData.Port).
Validate(func(s string) error {
if m.siteFormData.SiteType != "port" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
}
if v < 1 || v > 65535 {
return fmt.Errorf("port must be 1-65535")
if v < 0 || v > 65535 {
return fmt.Errorf("port must be 0-65535")
}
return nil
}),
@@ -616,9 +615,6 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("7").
Value(&m.siteFormData.Threshold).
Validate(func(s string) error {
if !m.siteFormData.CheckSSL {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -632,9 +628,6 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
Placeholder("0").
Value(&m.siteFormData.Retries).
Validate(func(s string) error {
if m.siteFormData.SiteType == "group" {
return nil
}
v, err := strconv.Atoi(s)
if err != nil {
return fmt.Errorf("must be a number")
@@ -649,7 +642,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
).Title("Advanced").WithHideFunc(func() bool {
return m.siteFormData.SiteType == "group"
}),
).WithTheme(m.theme.HuhTheme())
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
}
+1 -1
View File
@@ -94,7 +94,7 @@ func (m *Model) initUserHuhForm() tea.Cmd {
huh.NewOption("Admin", "admin"),
).Value(&m.userFormData.Role),
).Title("SSH Access"),
).WithTheme(m.theme.HuhTheme())
).WithTheme(huh.ThemeDracula())
return m.huhForm.Init()
}
+19 -5
View File
@@ -6,11 +6,25 @@ import (
)
var (
tableHeaderStyle lipgloss.Style
tableCellStyle lipgloss.Style
tableSelectedStyle lipgloss.Style
tableBorderStyle lipgloss.Style
tableZebraStyle lipgloss.Style
tableHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true).
Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().
Padding(0, 1).
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#3b3b5c"))
tableBorderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#444"))
tableZebraStyle = lipgloss.NewStyle().
Padding(0, 1).
Background(lipgloss.Color("#1a1a2e"))
)
type StyleOverride func(row, col int) *lipgloss.Style
-191
View File
@@ -1,191 +0,0 @@
package tui
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
type Theme struct {
Name string
// Base layers
Bg lipgloss.Color
Surface lipgloss.Color
Panel lipgloss.Color
Border lipgloss.Color
// Text
Fg lipgloss.Color
Muted lipgloss.Color
Subtle lipgloss.Color
// Semantic
Success lipgloss.Color
Warning lipgloss.Color
Danger lipgloss.Color
Info lipgloss.Color
Accent lipgloss.Color
Purple lipgloss.Color
// Table
ZebraBg lipgloss.Color
// Selection
SelectedFg lipgloss.Color
SelectedBg lipgloss.Color
}
var themes = []Theme{
themeFlexokiDark,
themeTokyoNight,
themeCatppuccinMocha,
themeNord,
themeGruvbox,
}
var themeFlexokiDark = Theme{
Name: "Flexoki Dark",
Bg: "#1C1B1A",
Surface: "#282726",
Panel: "#343331",
Border: "#575653",
Fg: "#CECDC3",
Muted: "#878580",
Subtle: "#6F6E69",
Success: "#879A39",
Warning: "#D0A215",
Danger: "#D14D41",
Info: "#4385BE",
Accent: "#3AA99F",
Purple: "#8B7EC8",
ZebraBg: "#222120",
SelectedFg: "#FFFCF0",
SelectedBg: "#403E3C",
}
var themeTokyoNight = Theme{
Name: "Tokyo Night",
Bg: "#1a1b26",
Surface: "#24283b",
Panel: "#292e42",
Border: "#3b4261",
Fg: "#c0caf5",
Muted: "#a9b1d6",
Subtle: "#565f89",
Success: "#9ece6a",
Warning: "#e0af68",
Danger: "#f7768e",
Info: "#7aa2f7",
Accent: "#7dcfff",
Purple: "#bb9af7",
ZebraBg: "#1c1d28",
SelectedFg: "#c0caf5",
SelectedBg: "#292e42",
}
var themeGruvbox = Theme{
Name: "Gruvbox",
Bg: "#282828",
Surface: "#3c3836",
Panel: "#504945",
Border: "#665c54",
Fg: "#ebdbb2",
Muted: "#bdae93",
Subtle: "#7c6f64",
Success: "#b8bb26",
Warning: "#fabd2f",
Danger: "#fb4934",
Info: "#83a598",
Accent: "#8ec07c",
Purple: "#d3869b",
ZebraBg: "#2a2a2a",
SelectedFg: "#fbf1c7",
SelectedBg: "#504945",
}
var themeCatppuccinMocha = Theme{
Name: "Catppuccin Mocha",
Bg: "#1e1e2e",
Surface: "#313244",
Panel: "#45475a",
Border: "#585b70",
Fg: "#cdd6f4",
Muted: "#a6adc8",
Subtle: "#6c7086",
Success: "#a6e3a1",
Warning: "#f9e2af",
Danger: "#f38ba8",
Info: "#89b4fa",
Accent: "#94e2d5",
Purple: "#cba6f7",
ZebraBg: "#232334",
SelectedFg: "#cdd6f4",
SelectedBg: "#45475a",
}
var themeNord = Theme{
Name: "Nord",
Bg: "#2e3440",
Surface: "#3b4252",
Panel: "#434c5e",
Border: "#4c566a",
Fg: "#d8dee9",
Muted: "#d8dee9",
Subtle: "#4c566a",
Success: "#a3be8c",
Warning: "#ebcb8b",
Danger: "#bf616a",
Info: "#81a1c1",
Accent: "#88c0d0",
Purple: "#b48ead",
ZebraBg: "#323845",
SelectedFg: "#eceff4",
SelectedBg: "#434c5e",
}
func (t Theme) HuhTheme() *huh.Theme {
ht := huh.ThemeBase()
ht.Focused.Base = ht.Focused.Base.BorderForeground(t.Border)
ht.Focused.Card = ht.Focused.Base
ht.Focused.Title = ht.Focused.Title.Foreground(t.Accent).Bold(true)
ht.Focused.NoteTitle = ht.Focused.NoteTitle.Foreground(t.Accent).Bold(true).MarginBottom(1)
ht.Focused.Description = ht.Focused.Description.Foreground(t.Muted)
ht.Focused.ErrorIndicator = ht.Focused.ErrorIndicator.Foreground(t.Danger)
ht.Focused.ErrorMessage = ht.Focused.ErrorMessage.Foreground(t.Danger)
ht.Focused.SelectSelector = ht.Focused.SelectSelector.Foreground(t.Purple)
ht.Focused.NextIndicator = ht.Focused.NextIndicator.Foreground(t.Purple)
ht.Focused.PrevIndicator = ht.Focused.PrevIndicator.Foreground(t.Purple)
ht.Focused.Option = ht.Focused.Option.Foreground(t.Fg)
ht.Focused.MultiSelectSelector = ht.Focused.MultiSelectSelector.Foreground(t.Purple)
ht.Focused.SelectedOption = ht.Focused.SelectedOption.Foreground(t.Success)
ht.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(t.Success).SetString("✓ ")
ht.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.Subtle).SetString("• ")
ht.Focused.UnselectedOption = ht.Focused.UnselectedOption.Foreground(t.Fg)
ht.Focused.FocusedButton = ht.Focused.FocusedButton.Foreground(t.Bg).Background(t.Accent)
ht.Focused.Next = ht.Focused.FocusedButton
ht.Focused.BlurredButton = ht.Focused.BlurredButton.Foreground(t.Fg).Background(t.Surface)
ht.Focused.TextInput.Cursor = ht.Focused.TextInput.Cursor.Foreground(t.Accent)
ht.Focused.TextInput.Placeholder = ht.Focused.TextInput.Placeholder.Foreground(t.Subtle)
ht.Focused.TextInput.Prompt = ht.Focused.TextInput.Prompt.Foreground(t.Purple)
ht.Blurred = ht.Focused
ht.Blurred.Base = ht.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
ht.Blurred.Card = ht.Blurred.Base
ht.Blurred.NextIndicator = lipgloss.NewStyle()
ht.Blurred.PrevIndicator = lipgloss.NewStyle()
ht.Group.Title = ht.Focused.Title
ht.Group.Description = ht.Focused.Description
return ht
}
func themeByName(name string) Theme {
for _, t := range themes {
if t.Name == name {
return t
}
}
return themes[0]
}
+19 -61
View File
@@ -3,15 +3,14 @@ package tui
import (
"encoding/json"
"fmt"
"go-upkeep/internal/models"
"go-upkeep/internal/monitor"
"go-upkeep/internal/store"
"math"
"sort"
"strings"
"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"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/harmonica"
@@ -21,34 +20,16 @@ import (
)
var (
subtleStyle lipgloss.Style
specialStyle lipgloss.Style
warnStyle lipgloss.Style
dangerStyle lipgloss.Style
titleStyle lipgloss.Style
activeTab lipgloss.Style
inactiveTab lipgloss.Style
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"})
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"})
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"})
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true)
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(lipgloss.Color("#7D56F4")).Foreground(lipgloss.Color("#7D56F4")).Bold(true).Padding(0, 1)
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"})
)
func applyTheme(t Theme) {
subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle)
specialStyle = lipgloss.NewStyle().Foreground(t.Success)
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1)
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg)
tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border)
tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg)
siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent)
maintStyle = lipgloss.NewStyle().Foreground(t.Purple)
}
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
const (
@@ -103,8 +84,6 @@ type Model struct {
collapsed map[int]bool
store store.Store
engine *monitor.Engine
theme Theme
themeIndex int
// harmonica animation state
pulseSpring harmonica.Spring
@@ -128,19 +107,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
z := zone.New()
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
collapsed := loadCollapsed(s)
themeName, _ := s.GetPreference("theme")
theme := themeByName(themeName)
themeIdx := 0
for i, t := range themes {
if t.Name == theme.Name {
themeIdx = i
break
}
}
applyTheme(theme)
return Model{
state: stateDashboard,
logViewport: vpLogs,
@@ -151,8 +117,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
zones: z,
pulseSpring: spring,
collapsed: collapsed,
theme: theme,
themeIndex: themeIdx,
}
}
@@ -494,11 +458,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshData()
}
}
case "T":
m.themeIndex = (m.themeIndex + 1) % len(themes)
m.theme = themes[m.themeIndex]
applyTheme(m.theme)
_ = m.store.SetPreference("theme", m.theme.Name)
case "d", "backspace":
if m.currentTab == 0 && len(m.sites) > 0 {
m.deleteID = m.sites[m.cursor].ID
@@ -764,7 +723,7 @@ func (m Model) View() string {
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Danger).
BorderForeground(lipgloss.Color("#F25D94")).
Padding(1, 3).
Render(msg + "\n\n" + hint)
return lipgloss.NewStyle().Padding(2, 4).Render(box)
@@ -916,19 +875,19 @@ func (m Model) viewDashboard() string {
var footer string
if m.filterMode {
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│")
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
} else {
var keys string
switch m.currentTab {
case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Tab]Switch [q]Quit"
case 4:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
keys = "[n]New [x]End [d]Del [Tab]Switch [q]Quit"
case 5:
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
default:
keys = "[T]Theme [Tab]Switch [q]Quit"
keys = "[Tab]Switch [q]Quit"
}
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
if m.filterText != "" && m.currentTab == 0 {
@@ -957,9 +916,8 @@ func siteOrder(s models.Site) int {
}
func limitStr(text string, max int) string {
runes := []rune(text)
if len(runes) > max {
return string(runes[:max-3]) + "..."
if len(text) > max {
return text[:max-3] + "..."
}
return text
}