Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions server/internal/app/metrics_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,25 +73,41 @@ func metricsBearerMatches(authHeader, token string) bool {
return subtle.ConstantTimeCompare([]byte(presented), []byte(token)) == 1
}

// defaultMetricsPath is where the scrape endpoint lives when prometheus.path
// is unset.
const defaultMetricsPath = "/metrics"

// metricsPath is the path the scrape endpoint is registered on.
func metricsPath(cfg *config.Config) string {
if cfg.Prometheus.Path == "" {
return defaultMetricsPath
}

return cfg.Prometheus.Path
}

// logMetricsScrapeTokenState logs the resolved /metrics access state at boot,
// once InitializeSystemConfig has overlaid metrics.scrape_token onto cfg —
// this runs whether the token came from the environment or the database, so
// the log always reflects what a request will actually see. A no-op when
// Prometheus is disabled outright: that state is already logged by
// SetupRoutes not registering the route at all.
// the log always reflects what a request will actually see. It is the only
// boot line about the endpoint: SetupRoutes registers the route without
// logging, since without a token the route answers 404. A no-op when
// Prometheus is disabled outright.
func logMetricsScrapeTokenState(ctx context.Context, cfg *config.Config) {
if !cfg.Prometheus.Enabled {
return
}

if cfg.Prometheus.ScrapeToken == "" {
slog.InfoContext(ctx,
"metrics endpoint disabled, set metrics.scrape_token to enable scraping")
"metrics endpoint disabled, set metrics.scrape_token to enable scraping",
"path", metricsPath(cfg))

return
}

slog.InfoContext(ctx, "metrics endpoint requires a bearer scrape token")
slog.InfoContext(ctx, "metrics endpoint requires a bearer scrape token",
"path", metricsPath(cfg))
}

// writeMetricsUnauthorized writes the generic 401 body for a missing or
Expand Down
2 changes: 2 additions & 0 deletions server/internal/app/metrics_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ func TestLogMetricsScrapeTokenState(t *testing.T) {
cfg.Prometheus.ScrapeToken = ""
logMetricsScrapeTokenState(ctx, cfg)
r.Contains(buf.String(), "metrics endpoint disabled, set metrics.scrape_token to enable scraping")
r.Contains(buf.String(), "path=/metrics")
r.NotContains(buf.String(), "metrics endpoint enabled", "one accurate line, never a contradicting one")

buf = captureLogs(t)
cfg.Prometheus.ScrapeToken = "set"
Expand Down
23 changes: 23 additions & 0 deletions server/internal/app/sentry_flush_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package app

import (
"testing"

"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/require"
)

// TestFlushSentryWithoutDSNIsSilent pins that a server with Sentry disabled
// (no DSN, so initSentry never ran sentry.Init) shuts down without the
// "Sentry flush timed out" warning: there is no queue to flush.
//
//nolint:paralleltest // captureLogs swaps the process-wide slog default (see its comment)
func TestFlushSentryWithoutDSNIsSilent(t *testing.T) {
r := require.New(t)
r.False(sentry.CurrentHub().Client().IsEnabled(), "no test in this package initializes Sentry")

buf := captureLogs(t)
flushSentry(t.Context())

r.Empty(buf.String())
}
97 changes: 63 additions & 34 deletions server/internal/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -534,24 +534,9 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
localfs.Register()
s3fs.Register()

// Initialize VAPID keys for Web Push. Auto-generates and persists to
// app_settings when not pre-provisioned via env vars.
if pub, priv, err := webpushpkg.GetOrCreateVAPIDKeys(ctx, webpushpkg.Config{
VAPIDPublicKey: cfg.WebPush.VAPIDPublicKey,
VAPIDPrivateKey: cfg.WebPush.VAPIDPrivateKey,
Subject: cfg.WebPush.Subject,
Enabled: cfg.WebPush.Enabled,
}, dbService); err != nil {
slog.WarnContext(ctx, "webpush: VAPID key initialization failed — web push disabled", "err", err)
} else {
cfg.WebPush.VAPIDPublicKey = pub
cfg.WebPush.VAPIDPrivateKey = priv
svcList.WebPushOptions = webpushpkg.Options{
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
Subject: cfg.WebPush.Subject,
}
}
// The VAPID keys for Web Push are not loaded here: they live in
// app_settings, which does not exist before the migrations. Initialize
// loads them (InitializeWebPush).

server := &Server{
dbService: dbService,
Expand Down Expand Up @@ -2439,15 +2424,9 @@ func (s *Server) SetupRoutes(ctx context.Context) {
prommetrics.Register(prometheus.DefaultRegisterer)
s.registerSubsystemMetrics(prometheus.DefaultRegisterer)

metricsPath := s.config.Prometheus.Path
if metricsPath == "" {
metricsPath = "/metrics"
}

mainGroup.GET(metricsPath, s.metricsHandler())

slog.InfoContext(ctx, "Prometheus metrics endpoint enabled", "path", metricsPath,
"tokenConfigured", s.config.Prometheus.ScrapeToken != "")
// Not logged here: whether the route answers depends on the scrape
// token, so logMetricsScrapeTokenState logs the one line that says so.
mainGroup.GET(metricsPath(s.config), s.metricsHandler())
}

// Test/dev fixture routes. Everything registered inside the RunMode=="test"
Expand Down Expand Up @@ -2597,6 +2576,23 @@ func initSentry(cfg config.SentryConfig) error {
return nil
}

// sentryFlushTimeout bounds how long shutdown waits for queued Sentry events.
const sentryFlushTimeout = 2 * time.Second

// flushSentry sends the queued Sentry events before shutdown. Without a DSN
// initSentry never installs a client: the hub holds sentry-go's disabled
// no-op client, there is nothing to send, and sentry.Flush returns false,
// which would read as a timeout for a queue that does not exist.
func flushSentry(ctx context.Context) {
if !sentry.CurrentHub().Client().IsEnabled() {
return
}

if !sentry.Flush(sentryFlushTimeout) {
slog.WarnContext(ctx, "Sentry flush timed out, some events may be lost")
}
}

// isPublicCORSPath reports whether path falls under a genuinely public,
// credential-free surface: public status pages and their assets, the
// embeddable widget, or the PostHog ingest proxy. A request under one of
Expand Down Expand Up @@ -4089,11 +4085,7 @@ func (s *Server) startCheckWorker(ctx context.Context) {
func (s *Server) Close(ctx context.Context) error {
var closeErr error

// Flush pending Sentry events
const sentryFlushTimeout = 2 * time.Second
if !sentry.Flush(sentryFlushTimeout) {
slog.WarnContext(ctx, "Sentry flush timed out, some events may be lost")
}
flushSentry(ctx)

// Flush buffered product-analytics events. No-op (and instant) when
// analytics is not configured.
Expand Down Expand Up @@ -4212,9 +4204,46 @@ func compressionWrapper(enabled bool) func(http.Handler) http.Handler {
}
}

// Initialize initializes the database (runs migrations).
// Initialize runs the database migrations, then loads the startup state that
// lives in the database (the Web Push VAPID keys).
func (s *Server) Initialize(ctx context.Context) error {
return s.dbService.Initialize(ctx)
if err := s.dbService.Initialize(ctx); err != nil {
return err
}

s.InitializeWebPush(ctx)

return nil
}

// InitializeWebPush loads the VAPID keypair for Web Push, creating and storing
// it on the first boot when it is not pre-provisioned through the environment.
// It needs a migrated database (the keys live in app_settings): Initialize
// calls it right after the migrations, and a caller that migrates the
// database some other way calls it before SetupRoutes. A database that cannot
// be read or written disables web push for this run, with one WARN.
func (s *Server) InitializeWebPush(ctx context.Context) {
cfg := s.config

pub, priv, err := webpushpkg.GetOrCreateVAPIDKeys(ctx, webpushpkg.Config{
VAPIDPublicKey: cfg.WebPush.VAPIDPublicKey,
VAPIDPrivateKey: cfg.WebPush.VAPIDPrivateKey,
Subject: cfg.WebPush.Subject,
Enabled: cfg.WebPush.Enabled,
}, s.dbService)
if err != nil {
slog.WarnContext(ctx, "webpush: VAPID key initialization failed, web push disabled", "err", err)

return
}

cfg.WebPush.VAPIDPublicKey = pub
cfg.WebPush.VAPIDPrivateKey = priv
s.services.WebPushOptions = webpushpkg.Options{
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
Subject: cfg.WebPush.Subject,
}
}

// InitializeSystemConfig loads system configuration from the database.
Expand Down
90 changes: 90 additions & 0 deletions server/internal/app/webpush_boot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package app

import (
"context"
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/fclairamb/solidping/server/internal/config"
)

const generatedVAPIDLine = "Generated VAPID keys"

// bootForWebPush runs NewServer then Initialize on a brand-new config, the way
// main does, and returns the server and its captured logs. A new config per
// boot matters: the first boot writes the keys into its config, and reusing it
// would take the env-provisioned path and hide a key that was never stored.
func bootForWebPush(t *testing.T, dbType, dir string) (*Server, string) {
t.Helper()

r := require.New(t)
ctx := context.Background()

cfg := &config.Config{}
cfg.Database.Type = dbType
cfg.Database.Dir = dir
cfg.Auth.JWTSecret = "webpush-boot-secret"

buf := captureLogs(t)

server, err := NewServer(ctx, cfg)
r.NoError(err)

r.Empty(server.Services().WebPushOptions.VAPIDPublicKey,
"the keys live in app_settings, which does not exist before the migrations")

r.NoError(server.Initialize(ctx))

pub := server.Services().WebPushOptions.VAPIDPublicKey
r.NotEmpty(pub)
r.Equal(pub, cfg.WebPush.VAPIDPublicKey)

return server, buf.String()
}

func requireCleanWebPushBoot(t *testing.T, logs string) {
t.Helper()

r := require.New(t)
r.NotContains(logs, "no such table")
r.NotContains(logs, "SQL query failed")
r.NotContains(logs, "level=WARN msg=\"webpush:")
}

// TestWebPushKeysCreatedAfterMigrationsAndStable covers spec 2026-10-12-03:
// the first boot of a fresh database creates and stores the keypair without a
// warning, and a restart serves the same public key without generating again.
//
//nolint:paralleltest // captureLogs swaps the process-wide slog default (see its comment)
func TestWebPushKeysCreatedAfterMigrationsAndStable(t *testing.T) {
r := require.New(t)
dir := t.TempDir()

first, logs := bootForWebPush(t, "sqlite", dir)
requireCleanWebPushBoot(t, logs)
r.Equal(1, strings.Count(logs, generatedVAPIDLine), logs)

firstKey := first.Services().WebPushOptions.VAPIDPublicKey
r.NoError(first.dbService.Close())

second, logs := bootForWebPush(t, "sqlite", dir)
t.Cleanup(func() { _ = second.dbService.Close() })

requireCleanWebPushBoot(t, logs)
r.NotContains(logs, generatedVAPIDLine)
r.Equal(firstKey, second.Services().WebPushOptions.VAPIDPublicKey,
"a browser subscribed during the first run must keep working after a restart")
}

// TestWebPushKeysInMemoryBoot is the sqlite-memory variant: one clean boot.
//
//nolint:paralleltest // captureLogs swaps the process-wide slog default (see its comment)
func TestWebPushKeysInMemoryBoot(t *testing.T) {
server, logs := bootForWebPush(t, dbTypeSQLiteMemory, "")
t.Cleanup(func() { _ = server.dbService.Close() })

requireCleanWebPushBoot(t, logs)
require.Equal(t, 1, strings.Count(logs, generatedVAPIDLine), logs)
}
5 changes: 4 additions & 1 deletion server/internal/checkers/checkmysql/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ const tunnelNetwork = "solidping-tunnel"

var errNoTunnelDialer = errors.New("mysql tunnel dial: no tunnel dialer on connection context")

//nolint:gochecknoinits // one-time global tunnel-network registration (mysql's dial registry never shrinks)
//nolint:gochecknoinits // one-time process-wide registrations: the dial registry (never shrinks) and the logger
func init() {
// SetLogger only fails on a nil logger.
_ = mysql.SetLogger(mysqlLogger{})

mysql.RegisterDialContext(tunnelNetwork, func(ctx context.Context, addr string) (net.Conn, error) {
// The tunnel dialer, or the egress guard when the policy is
// enforcing (spec 2026-09-25-19): both ride the connection context.
Expand Down
23 changes: 23 additions & 0 deletions server/internal/checkers/checkmysql/liblog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package checkmysql

import (
"context"
"fmt"
"log/slog"
)

// mysqlLogger routes go-sql-driver/mysql's own log lines into slog at DEBUG.
// By default the driver prints them straight to stderr ("[mysql] 2026/10/12
// 06:03:59 packets.go:58: unexpected EOF"), outside the log format and level.
// The failure already reaches the check result, so the driver's line is only
// diagnostic detail. Installed by this package's init, next to the dial
// registration.
type mysqlLogger struct{}

func (mysqlLogger) Print(v ...any) {
if !slog.Default().Enabled(context.Background(), slog.LevelDebug) {
return
}

slog.Debug(fmt.Sprint(v...), "component", "go-sql-driver/mysql")
}
29 changes: 29 additions & 0 deletions server/internal/checkers/checkredis/liblog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package checkredis

import (
"context"
"fmt"
"log/slog"

"github.com/redis/go-redis/v9"
)

// redisLogger routes go-redis's own log lines into slog at DEBUG. By default
// go-redis prints them straight to stderr ("redis: 2026/10/12 06:03:59
// pool.go:770: redis: connection pool: failed to dial after 5 attempts..."),
// outside the log format and level. The failure already reaches the check
// result, so the library's line is only diagnostic detail.
type redisLogger struct{}

func (redisLogger) Printf(ctx context.Context, format string, v ...any) {
if !slog.Default().Enabled(ctx, slog.LevelDebug) {
return
}

slog.DebugContext(ctx, fmt.Sprintf(format, v...), "component", "go-redis")
}

//nolint:gochecknoinits // go-redis's logger is process-global: set it once, before any client exists
func init() {
redis.SetLogger(redisLogger{})
}
3 changes: 1 addition & 2 deletions server/internal/checkworker/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,7 @@ func (r *CheckWorker) registerWorker(ctx context.Context) error {
// hostname truncated to config.WorkerHostnameMaxLen. Config.Validate() has
// already rejected a slug the database CHECK constraint would refuse.
identity := r.config.WorkerIdentity()
identity.WarnIfTruncated(ctx, r.logger)
identity.WarnIfSanitized(ctx, r.logger)
identity.LogDerivation(ctx, r.logger, r.config.WorkerSlugMayCollide())

region := "default"
if r.config.Server.CheckWorker.Region != "" {
Expand Down
Loading
Loading