fix(security): phase 3 medium reliability and hardening
CI / test (pull_request) Successful in 4m23s
CI / lint (pull_request) Successful in 1m11s

- Fail hard on critical migration errors (ignore only "already exists")
- Cache SSH user keys with 30s TTL (avoid DB query per auth attempt)
- Configure DB connection pooling (25 open, 5 idle, 5m lifetime)
- Enable SQLite WAL mode for concurrent read/write
- Optimize check history pruning (only prune above 1100 rows)
- Add security headers: X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy
- Add CORS policy on /status/json via UPTOP_CORS_ORIGIN env var
- Add HTTP request logging middleware (method, path, status, duration, IP)
- Fix config file permissions from 0644 to 0600
- Pin Docker images: golang:1.24-alpine3.21, alpine:3.21
- Fix Docker CI tag pattern for CalVer (was semver)
- Pass build args (VERSION, COMMIT, BUILD_DATE) to Docker build
This commit is contained in:
2026-05-26 16:57:03 -04:00
parent 7a8f2ad15b
commit bd561d9a5e
7 changed files with 125 additions and 27 deletions
+6 -4
View File
@@ -3,7 +3,7 @@ name: Publish Release
on: on:
push: push:
tags: tags:
- 'v*' - '[0-9]*'
jobs: jobs:
push_to_registry: push_to_registry:
@@ -31,9 +31,7 @@ jobs:
with: with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop images: ${{ secrets.DOCKERHUB_USERNAME }}/uptop
tags: | tags: |
# This turns git tag "v1.0.0" into docker tag "1.0.0" type=match,pattern=\d+\.\d+\.\d+
type=semver,pattern={{version}}
# This updates the "latest" tag to this version
type=raw,value=latest type=raw,value=latest
- name: Build and push - name: Build and push
@@ -43,3 +41,7 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
COMMIT=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
+2 -2
View File
@@ -1,5 +1,5 @@
# --- Stage 1: Builder --- # --- Stage 1: Builder ---
FROM golang:alpine AS builder FROM golang:1.24-alpine3.21 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 ./
@@ -12,7 +12,7 @@ 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 go build -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.21
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
+50 -8
View File
@@ -10,6 +10,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"strconv" "strconv"
"sync"
"syscall" "syscall"
"time" "time"
@@ -393,6 +394,7 @@ func runServe(args []string) {
TLSKey: tlsKey, TLSKey: tlsKey,
ClusterMode: clusterMode, ClusterMode: clusterMode,
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true", 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{
@@ -401,7 +403,8 @@ 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())
@@ -431,12 +434,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(".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) {
@@ -505,17 +508,56 @@ func seedDemoData(s store.Store) {
} }
} }
func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool { type keyCache struct {
users, err := db.GetAllUsers() mu sync.RWMutex
keys []ssh.PublicKey
updated time.Time
ttl time.Duration
db store.Store
}
func newKeyCache(db store.Store) *keyCache {
return &keyCache{db: db, ttl: 30 * time.Second}
}
func (c *keyCache) refresh() {
users, err := c.db.GetAllUsers()
if err != nil { if err != nil {
return false return
} }
keys := make([]ssh.PublicKey, 0, len(users))
for _, u := range users { for _, u := range users {
allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey)) k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
if err != nil { if err != nil {
continue continue
} }
if ssh.KeysEqual(allowedKey, incomingKey) { keys = append(keys, k)
}
c.mu.Lock()
c.keys = keys
c.updated = time.Now()
c.mu.Unlock()
}
func (c *keyCache) Invalidate() {
c.mu.Lock()
c.updated = time.Time{}
c.mu.Unlock()
}
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
c.mu.RLock()
stale := time.Since(c.updated) > c.ttl
c.mu.RUnlock()
if stale {
c.refresh()
}
c.mu.RLock()
defer c.mu.RUnlock()
for _, k := range c.keys {
if ssh.KeysEqual(k, incomingKey) {
return true return true
} }
} }
+4 -3
View File
@@ -2,11 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"os" "os"
"sort" "sort"
"gitea.lerkolabs.com/lerko/uptop/internal/models"
"gitea.lerkolabs.com/lerko/uptop/internal/store"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -142,7 +143,7 @@ func WriteFile(f *File, path string) error {
_, err = os.Stdout.Write(data) _, err = os.Stdout.Write(data)
return err return err
} }
return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable return os.WriteFile(path, data, 0600)
} }
func LoadFile(path string) (*File, error) { func LoadFile(path string) (*File, error) {
+36 -2
View File
@@ -187,6 +187,7 @@ type ServerConfig struct {
TLSKey string TLSKey string
ClusterMode string ClusterMode string
MetricsPublic bool 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 {
@@ -449,6 +450,9 @@ 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
})) }))
@@ -458,9 +462,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.") fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
} }
var handler http.Handler = mux handler := loggingMiddleware(securityHeadersMiddleware(mux))
if cfg.TLSCert != "" { if cfg.TLSCert != "" {
handler = hstsMiddleware(mux) handler = hstsMiddleware(handler)
} }
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
@@ -488,6 +492,36 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
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 { func hstsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
+8 -1
View File
@@ -10,7 +10,14 @@ import (
type SQLiteDialect struct{} type SQLiteDialect struct{}
func NewSQLiteStore(path string) (*SQLStore, error) { func NewSQLiteStore(path string) (*SQLStore, error) {
return NewSQLStore("sqlite3", path, &SQLiteDialect{}) s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
if err != nil {
return nil, err
}
if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
log.Printf("WAL mode failed: %v", err)
}
return s, nil
} }
func (d *SQLiteDialect) DriverName() string { return "sqlite3" } func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
+18 -6
View File
@@ -6,7 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "strings"
"time" "time"
"gitea.lerkolabs.com/lerko/uptop/internal/models" "gitea.lerkolabs.com/lerko/uptop/internal/models"
@@ -24,6 +24,9 @@ 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
} }
@@ -70,7 +73,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
@@ -342,10 +349,15 @@ 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 > 1100 {
return err _, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
)`), siteID, siteID)
return err
}
return nil
} }
func (s *SQLStore) RegisterNode(node models.ProbeNode) error { func (s *SQLStore) RegisterNode(node models.ProbeNode) error {