fix(security): phase 3 medium reliability and hardening
- 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:
@@ -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
@@ -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
|
||||||
|
|||||||
+51
-9
@@ -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
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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,11 +349,16 @@ func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
var count int
|
||||||
|
_ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
|
||||||
|
if count > 1100 {
|
||||||
_, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
|
_, 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
|
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
|
||||||
)`), siteID, siteID)
|
)`), 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)
|
||||||
|
|||||||
Reference in New Issue
Block a user