fix(security): phase 1 critical fixes for public release
- Redact PostgreSQL DSN password from stdout/logs - Harden .dockerignore to exclude .ssh/, .claude/, *.db, *.local files - SSRF protection: block private/loopback/link-local IPs by default (UPTOP_ALLOW_PRIVATE_TARGETS=true to override for homelab use) - Fix email header injection via CRLF in monitor names - AES-256-GCM encryption for alert credentials at rest (UPTOP_ENCRYPTION_KEY env var, migrate-secrets subcommand) - TLS support for HTTP server (UPTOP_TLS_CERT/UPTOP_TLS_KEY) with HSTS header when TLS enabled
This commit is contained in:
+13
-1
@@ -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
|
||||||
|
|||||||
+126
-23
@@ -5,6 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strconv"
|
||||||
|
"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 +21,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 +49,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 +72,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 +166,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,13 +285,19 @@ func runServe(args []string) {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
|
||||||
|
if probeAllowPrivate {
|
||||||
|
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
|
||||||
|
}
|
||||||
|
|
||||||
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
|
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
NodeName: nodeName,
|
NodeName: nodeName,
|
||||||
Region: nodeRegion,
|
Region: nodeRegion,
|
||||||
LeaderURL: clusterPeer,
|
LeaderURL: clusterPeer,
|
||||||
SharedKey: clusterKey,
|
SharedKey: clusterKey,
|
||||||
Interval: 30,
|
Interval: 30,
|
||||||
|
AllowPrivateTargets: probeAllowPrivate,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -232,21 +312,33 @@ 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.Printf("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.Printf("Database init error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -269,7 +361,12 @@ func runServe(args []string) {
|
|||||||
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 +381,17 @@ 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,
|
||||||
}, s, eng)
|
}, s, eng)
|
||||||
|
|
||||||
cluster.Start(ctx, cluster.Config{
|
cluster.Start(ctx, cluster.Config{
|
||||||
|
|||||||
+19
-5
@@ -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}
|
||||||
@@ -176,6 +177,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 +190,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,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))
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-13
@@ -6,21 +6,23 @@ 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"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProbeConfig struct {
|
type ProbeConfig struct {
|
||||||
NodeID string
|
NodeID string
|
||||||
NodeName string
|
NodeName string
|
||||||
Region string
|
Region string
|
||||||
LeaderURL string
|
LeaderURL string
|
||||||
SharedKey string
|
SharedKey string
|
||||||
Interval int
|
Interval int
|
||||||
|
AllowPrivateTargets bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||||
@@ -29,11 +31,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 +68,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 {
|
||||||
@@ -121,7 +130,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 +149,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,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)
|
||||||
|
|||||||
+35
-17
@@ -4,13 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
@@ -32,26 +33,43 @@ type Engine struct {
|
|||||||
probeResults map[int]map[string]NodeResult
|
probeResults map[int]map[string]NodeResult
|
||||||
aggStrategy AggregationStrategy
|
aggStrategy AggregationStrategy
|
||||||
|
|
||||||
db store.Store
|
db store.Store
|
||||||
insecureSkipVerify bool
|
insecureSkipVerify bool
|
||||||
strictClient *http.Client
|
allowPrivateTargets bool
|
||||||
insecureClient *http.Client
|
strictClient *http.Client
|
||||||
|
insecureClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(s store.Store) *Engine {
|
func NewEngine(s store.Store) *Engine {
|
||||||
|
return newEngine(s, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngineWithOpts(s store.Store, allowPrivateTargets bool) *Engine {
|
||||||
|
return newEngine(s, allowPrivateTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
||||||
|
dial := SafeDialContext(allowPrivateTargets)
|
||||||
return &Engine{
|
return &Engine{
|
||||||
liveState: make(map[int]models.Site),
|
liveState: make(map[int]models.Site),
|
||||||
histories: make(map[int]*SiteHistory),
|
histories: make(map[int]*SiteHistory),
|
||||||
tokenIndex: make(map[string]int),
|
tokenIndex: make(map[string]int),
|
||||||
probeResults: make(map[int]map[string]NodeResult),
|
probeResults: make(map[int]map[string]NodeResult),
|
||||||
aggStrategy: AggAnyDown,
|
aggStrategy: AggAnyDown,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
db: s,
|
allowPrivateTargets: allowPrivateTargets,
|
||||||
|
db: s,
|
||||||
strictClient: &http.Client{
|
strictClient: &http.Client{
|
||||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
||||||
|
DialContext: dial,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
insecureClient: &http.Client{
|
insecureClient: &http.Client{
|
||||||
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
|
||||||
|
DialContext: dial,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +369,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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-10
@@ -4,17 +4,18 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func checkSecret(got, want string) bool {
|
func checkSecret(got, want string) bool {
|
||||||
@@ -156,7 +157,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
||||||
@@ -399,17 +403,40 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" {
|
||||||
|
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler http.Handler = mux
|
||||||
|
if cfg.TLSCert != "" {
|
||||||
|
handler = hstsMiddleware(mux)
|
||||||
|
}
|
||||||
|
|
||||||
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}
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Printf("HTTP Server listening on %s\n", addr)
|
if cfg.TLSCert != "" && cfg.TLSKey != "" {
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
fmt.Printf("HTTPS Server listening on %s\n", addr)
|
||||||
log.Printf("HTTP server error: %v", err)
|
if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("HTTPS server error: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("HTTP Server listening on %s\n", addr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Printf("HTTP server error: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-20
@@ -6,15 +6,17 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
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) {
|
||||||
@@ -26,6 +28,24 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -140,15 +160,36 @@ 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
|
||||||
}
|
}
|
||||||
@@ -184,12 +225,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 +240,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user