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