diff --git a/dev/Tiltfile b/dev/Tiltfile index d23b561c54..875629a5c0 100644 --- a/dev/Tiltfile +++ b/dev/Tiltfile @@ -286,7 +286,7 @@ k8s_resource( # Build locally and load into minikube local_resource( 'build-sentinel-image', - 'docker build -t unkey/sentinel:latest -f Dockerfile.tilt .. && minikube image load unkey/sentinel:latest', + 'docker build -t unkey/sentinel:latest -f Dockerfile.tilt .. && minikube image load unkey/sentinel:latest && kubectl rollout restart deployment -n sentinel --selector=app.kubernetes.io/component=sentinel 2>/dev/null || true', deps=['../bin'], resource_deps=['build-unkey'], labels=['build'], diff --git a/svc/frontline/BUILD.bazel b/svc/frontline/BUILD.bazel index 83dc708f48..ce6e620ba0 100644 --- a/svc/frontline/BUILD.bazel +++ b/svc/frontline/BUILD.bazel @@ -27,6 +27,7 @@ go_library( "//pkg/tls", "//pkg/version", "//pkg/zen", + "//svc/frontline/internal/errorpage", "//svc/frontline/routes", "//svc/frontline/services/caches", "//svc/frontline/services/certmanager", diff --git a/svc/frontline/internal/errorpage/BUILD.bazel b/svc/frontline/internal/errorpage/BUILD.bazel new file mode 100644 index 0000000000..f38bdc8ea7 --- /dev/null +++ b/svc/frontline/internal/errorpage/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "errorpage", + srcs = [ + "doc.go", + "errorpage.go", + "interface.go", + ], + embedsrcs = ["error.go.tmpl"], + importpath = "github.com/unkeyed/unkey/svc/frontline/internal/errorpage", + visibility = ["//svc/frontline:__subpackages__"], +) diff --git a/svc/frontline/internal/errorpage/doc.go b/svc/frontline/internal/errorpage/doc.go new file mode 100644 index 0000000000..ca7529b27d --- /dev/null +++ b/svc/frontline/internal/errorpage/doc.go @@ -0,0 +1,19 @@ +// Package errorpage renders HTML error pages for frontline. +// +// Frontline shows error pages for its own errors (routing failures, proxy +// errors) and for sentinel errors (auth rejections, rate limits). The +// [Renderer] interface allows swapping the template, e.g. for custom +// domains with branded error pages. +// +// # Template +// +// The default implementation embeds error.go.tmpl at compile time and +// renders it with [html/template]. The template receives a [Data] struct +// and supports dark/light mode via prefers-color-scheme. +// +// # Content Negotiation +// +// This package only produces HTML. The caller (frontline middleware or +// proxy) is responsible for checking the Accept header and falling back +// to JSON when the client prefers it. +package errorpage diff --git a/svc/frontline/internal/errorpage/error.go.tmpl b/svc/frontline/internal/errorpage/error.go.tmpl new file mode 100644 index 0000000000..9c32567847 --- /dev/null +++ b/svc/frontline/internal/errorpage/error.go.tmpl @@ -0,0 +1,169 @@ + + +
+ + +%s
-Error: %s
- -`, escapedTitle, escapedTitle, escapedMessage, escapedErrorCode) -} diff --git a/svc/frontline/routes/BUILD.bazel b/svc/frontline/routes/BUILD.bazel index 94c834c7d6..6014aabb9d 100644 --- a/svc/frontline/routes/BUILD.bazel +++ b/svc/frontline/routes/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "//gen/rpc/ctrl", "//pkg/clock", "//pkg/zen", + "//svc/frontline/internal/errorpage", "//svc/frontline/middleware", "//svc/frontline/routes/acme", "//svc/frontline/routes/internal_health", diff --git a/svc/frontline/routes/register.go b/svc/frontline/routes/register.go index 360aa0faf3..f686df3405 100644 --- a/svc/frontline/routes/register.go +++ b/svc/frontline/routes/register.go @@ -14,7 +14,7 @@ import ( func Register(srv *zen.Server, svc *Services) { withLogging := zen.WithLogging(zen.SkipPaths("/_unkey/internal/", "/health/")) withPanicRecovery := zen.WithPanicRecovery() - withObservability := middleware.WithObservability(svc.Region) + withObservability := middleware.WithObservability(svc.Region, svc.ErrorPageRenderer) withTimeout := zen.WithTimeout(5 * time.Minute) defaultMiddlewares := []zen.Middleware{ @@ -56,7 +56,7 @@ func RegisterChallengeServer(srv *zen.Server, svc *Services) { []zen.Middleware{ zen.WithPanicRecovery(), withLogging, - middleware.WithObservability(svc.Region), + middleware.WithObservability(svc.Region, svc.ErrorPageRenderer), }, &acme.Handler{ RouterService: svc.RouterService, diff --git a/svc/frontline/routes/services.go b/svc/frontline/routes/services.go index fe9d36b812..a43ef7b7cb 100644 --- a/svc/frontline/routes/services.go +++ b/svc/frontline/routes/services.go @@ -3,14 +3,16 @@ package routes import ( "github.com/unkeyed/unkey/gen/rpc/ctrl" "github.com/unkeyed/unkey/pkg/clock" + "github.com/unkeyed/unkey/svc/frontline/internal/errorpage" "github.com/unkeyed/unkey/svc/frontline/services/proxy" "github.com/unkeyed/unkey/svc/frontline/services/router" ) type Services struct { - Region string - RouterService router.Service - ProxyService proxy.Service - Clock clock.Clock - AcmeClient ctrl.AcmeServiceClient + Region string + RouterService router.Service + ProxyService proxy.Service + Clock clock.Clock + AcmeClient ctrl.AcmeServiceClient + ErrorPageRenderer errorpage.Renderer } diff --git a/svc/frontline/run.go b/svc/frontline/run.go index e2ed34ccb5..8c93e1e4ec 100644 --- a/svc/frontline/run.go +++ b/svc/frontline/run.go @@ -28,6 +28,7 @@ import ( pkgtls "github.com/unkeyed/unkey/pkg/tls" "github.com/unkeyed/unkey/pkg/version" "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/frontline/internal/errorpage" "github.com/unkeyed/unkey/svc/frontline/routes" "github.com/unkeyed/unkey/svc/frontline/services/caches" "github.com/unkeyed/unkey/svc/frontline/services/certmanager" @@ -259,11 +260,12 @@ func Run(ctx context.Context, cfg Config) error { acmeClient := ctrl.NewConnectAcmeServiceClient(ctrlv1connect.NewAcmeServiceClient(ptr.P(http.Client{}), cfg.CtrlAddr)) svcs := &routes.Services{ - Region: cfg.Region, - RouterService: routerSvc, - ProxyService: proxySvc, - Clock: clk, - AcmeClient: acmeClient, + Region: cfg.Region, + RouterService: routerSvc, + ProxyService: proxySvc, + Clock: clk, + AcmeClient: acmeClient, + ErrorPageRenderer: errorpage.NewRenderer(), } // Start HTTPS frontline server (main proxy server) diff --git a/svc/frontline/services/proxy/BUILD.bazel b/svc/frontline/services/proxy/BUILD.bazel index 11a8451438..597312290b 100644 --- a/svc/frontline/services/proxy/BUILD.bazel +++ b/svc/frontline/services/proxy/BUILD.bazel @@ -23,6 +23,7 @@ go_library( "//pkg/logger", "//pkg/timing", "//pkg/zen", + "//svc/frontline/internal/errorpage", "@org_golang_x_net//http2", ], ) diff --git a/svc/frontline/services/proxy/forward.go b/svc/frontline/services/proxy/forward.go index 2f7752311e..6bb0cf8b30 100644 --- a/svc/frontline/services/proxy/forward.go +++ b/svc/frontline/services/proxy/forward.go @@ -1,10 +1,14 @@ package proxy import ( + "bytes" + "encoding/json" "fmt" + "io" "net/http" "net/http/httputil" "net/url" + "strings" "time" "github.com/unkeyed/unkey/pkg/codes" @@ -12,6 +16,7 @@ import ( "github.com/unkeyed/unkey/pkg/logger" "github.com/unkeyed/unkey/pkg/timing" "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/frontline/internal/errorpage" ) type forwardConfig struct { @@ -81,11 +86,16 @@ func (s *service) forward(sess *zen.Session, cfg forwardConfig) error { }, }) - if resp.StatusCode >= 500 && resp.Header.Get("X-Unkey-Error-Source") == "sentinel" { - if sentinelTime := resp.Header.Get(timing.HeaderName); sentinelTime != "" { - sess.ResponseWriter().Header().Add(timing.HeaderName, sentinelTime) - } + if resp.Header.Get("X-Unkey-Error-Source") != "sentinel" { + return nil + } + + if sentinelTime := resp.Header.Get(timing.HeaderName); sentinelTime != "" { + sess.ResponseWriter().Header().Add(timing.HeaderName, sentinelTime) + } + // 5xx from sentinel → fault error → frontline observability handles content negotiation + if resp.StatusCode >= 500 { urn := codes.Frontline.Proxy.BadGateway.URN() switch resp.StatusCode { case http.StatusServiceUnavailable: @@ -103,6 +113,12 @@ func (s *service) forward(sess *zen.Session, cfg forwardConfig) error { ) } + // 4xx from sentinel (auth errors, rate limits) → rewrite to HTML if client prefers it, + // otherwise pass the JSON through untouched. + if resp.StatusCode >= 400 && wantsHTML(sess.Request()) { + return rewriteSentinelErrorAsHTML(resp, sess.RequestID(), s.errorPageRenderer) + } + return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { @@ -134,3 +150,86 @@ func (s *service) forward(sess *zen.Session, cfg forwardConfig) error { return nil } + +// wantsHTML returns true if the client prefers HTML over JSON based on the Accept header. +func wantsHTML(r *http.Request) bool { + accept := r.Header.Get("Accept") + if accept == "" { + return false + } + + for _, part := range strings.Split(accept, ",") { + mediaType := strings.TrimSpace(strings.SplitN(part, ";", 2)[0]) + switch mediaType { + case "text/html": + return true + case "application/json", "application/*", "*/*": + return false + } + } + + return false +} + +// sentinelError matches the JSON error structure returned by sentinel. +type sentinelError struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// rewriteSentinelErrorAsHTML reads the sentinel JSON error response and replaces +// the body with a styled HTML error page. The original status code is preserved. +func rewriteSentinelErrorAsHTML(resp *http.Response, requestID string, renderer errorpage.Renderer) error { + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + return nil // can't read body, let it pass through + } + + var parsed sentinelError + if err := json.Unmarshal(body, &parsed); err != nil { + // Not valid JSON, put the body back unchanged + resp.Body = io.NopCloser(bytes.NewReader(body)) + return nil + } + + message := parsed.Error.Message + if message == "" { + message = http.StatusText(resp.StatusCode) + } + + title := http.StatusText(resp.StatusCode) + if title == "" { + title = "Error" + } + + var docsURL string + if parsed.Error.Code != "" { + if code, parseErr := codes.ParseCode(parsed.Error.Code); parseErr == nil { + docsURL = code.DocsURL() + } + } + + htmlBody, renderErr := renderer.Render(errorpage.Data{ + StatusCode: resp.StatusCode, + Title: title, + Message: message, + ErrorCode: parsed.Error.Code, + DocsURL: docsURL, + RequestID: requestID, + }) + if renderErr != nil { + // Template render failed, put original body back + resp.Body = io.NopCloser(bytes.NewReader(body)) + return nil + } + + resp.Body = io.NopCloser(bytes.NewReader(htmlBody)) + resp.ContentLength = int64(len(htmlBody)) + resp.Header.Set("Content-Type", "text/html; charset=utf-8") + resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(htmlBody))) + + return nil +} diff --git a/svc/frontline/services/proxy/interface.go b/svc/frontline/services/proxy/interface.go index ec696cf8fd..a2b32527e8 100644 --- a/svc/frontline/services/proxy/interface.go +++ b/svc/frontline/services/proxy/interface.go @@ -8,6 +8,7 @@ import ( "github.com/unkeyed/unkey/pkg/clock" "github.com/unkeyed/unkey/pkg/db" "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/frontline/internal/errorpage" ) // Service defines the interface for proxying requests to sentinels or remote NLBs. @@ -54,4 +55,7 @@ type Config struct { // Transport allows passing a shared HTTP transport for connection pooling // If nil, a new transport will be created with the other config values Transport *http.Transport + + // ErrorPageRenderer renders HTML error pages for sentinel errors. + ErrorPageRenderer errorpage.Renderer } diff --git a/svc/frontline/services/proxy/service.go b/svc/frontline/services/proxy/service.go index bf4e85090f..b829d54174 100644 --- a/svc/frontline/services/proxy/service.go +++ b/svc/frontline/services/proxy/service.go @@ -16,17 +16,19 @@ import ( "github.com/unkeyed/unkey/pkg/fault" "github.com/unkeyed/unkey/pkg/logger" "github.com/unkeyed/unkey/pkg/zen" + "github.com/unkeyed/unkey/svc/frontline/internal/errorpage" "golang.org/x/net/http2" ) type service struct { - instanceID string - region string - apexDomain string - clock clock.Clock - transport *http.Transport - h2cTransport *http2.Transport - maxHops int + instanceID string + region string + apexDomain string + clock clock.Clock + transport *http.Transport + h2cTransport *http2.Transport + maxHops int + errorPageRenderer errorpage.Renderer } var _ Service = (*service)(nil) @@ -90,14 +92,20 @@ func New(cfg Config) (*service, error) { }, } + renderer := cfg.ErrorPageRenderer + if renderer == nil { + renderer = errorpage.NewRenderer() + } + return &service{ - instanceID: cfg.InstanceID, - region: cfg.Region, - apexDomain: cfg.ApexDomain, - clock: cfg.Clock, - transport: transport, - h2cTransport: h2cTransport, - maxHops: maxHops, + instanceID: cfg.InstanceID, + region: cfg.Region, + apexDomain: cfg.ApexDomain, + clock: cfg.Clock, + transport: transport, + h2cTransport: h2cTransport, + maxHops: maxHops, + errorPageRenderer: renderer, }, nil } diff --git a/svc/sentinel/middleware/observability.go b/svc/sentinel/middleware/observability.go index 3f8d192bdb..a846d48bb9 100644 --- a/svc/sentinel/middleware/observability.go +++ b/svc/sentinel/middleware/observability.go @@ -13,6 +13,7 @@ import ( "github.com/unkeyed/unkey/pkg/logger" "github.com/unkeyed/unkey/pkg/otel/tracing" "github.com/unkeyed/unkey/pkg/zen" + handler "github.com/unkeyed/unkey/svc/sentinel/routes/proxy" "go.opentelemetry.io/otel/attribute" ) @@ -245,6 +246,12 @@ func WithObservability(environmentID, region string) zen.Middleware { pageInfo := getErrorPageInfo(urn) statusCode = pageInfo.Status + // Ensure tracking has the resolved status for CH logging, + // in case WithProxyErrorHandling didn't set it (e.g. auth errors). + if tracking, ok := handler.SentinelTrackingFromContext(ctx); ok && tracking.ResponseStatus == 0 { + tracking.ResponseStatus = int32(statusCode) + } + errorType = categorizeErrorType(urn, statusCode, hasError) userMessage := pageInfo.Message