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
2 changes: 1 addition & 1 deletion dev/Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
1 change: 1 addition & 0 deletions svc/frontline/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions svc/frontline/internal/errorpage/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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__"],
)
19 changes: 19 additions & 0 deletions svc/frontline/internal/errorpage/doc.go
Original file line number Diff line number Diff line change
@@ -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
169 changes: 169 additions & 0 deletions svc/frontline/internal/errorpage/error.go.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.StatusCode}} {{.Title}}</title>
<style>
:root {
--bg: #09090b;
--fg: #fafafa;
--muted: #a1a1aa;
--dim: #71717a;
--faint: #52525b;
--border: #27272a;
--surface: #18181b;
}

@media (prefers-color-scheme: light) {
:root {
--bg: #fafafa;
--fg: #09090b;
--muted: #71717a;
--dim: #a1a1aa;
--faint: #a1a1aa;
--border: #e4e4e7;
--surface: #f4f4f5;
}
}

*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Liberation Mono", Menlo, Monaco, Consolas, monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
background: var(--bg);
color: var(--fg);
-webkit-font-smoothing: antialiased;
}

.container {
max-width: 480px;
width: 100%;
}

.header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}

.status {
font-size: 3rem;
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
}

.title {
font-size: 1rem;
font-weight: 400;
color: var(--muted);
}

.message {
margin-top: 1.25rem;
padding: 1rem 1.25rem;
font-size: 0.8125rem;
line-height: 1.7;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
}

.meta {
margin-top: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.75rem;
}

.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}

.meta-label {
color: var(--faint);
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.05em;
}

.meta-value {
color: var(--muted);
}

.meta-value a {
color: var(--muted);
text-decoration: underline;
text-decoration-color: var(--faint);
text-underline-offset: 3px;
transition: color 0.15s, text-decoration-color 0.15s;
}

.meta-value a:hover {
color: var(--fg);
text-decoration-color: var(--fg);
}

.footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.6875rem;
color: var(--faint);
}

.footer a {
color: var(--dim);
text-decoration: none;
}

.footer a:hover {
color: var(--fg);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="status">{{.StatusCode}}</div>
<div class="title">{{.Title}}</div>
</div>

<div class="message">{{.Message}}</div>

<div class="meta">
{{if .RequestID}}
<div class="meta-row">
<span class="meta-label">Request ID</span>
<span class="meta-value">{{.RequestID}}</span>
</div>
{{end}}
{{if .ErrorCode}}
<div class="meta-row">
<span class="meta-label">Code</span>
<span class="meta-value">{{if .DocsURL}}<a href="{{.DocsURL}}" target="_blank">{{.ErrorCode}}</a>{{else}}{{.ErrorCode}}{{end}}</span>
</div>
{{end}}
</div>

<div class="footer">
Need help? <a href="mailto:support@unkey.com">support@unkey.com</a>
</div>
</div>
</body>
</html>
32 changes: 32 additions & 0 deletions svc/frontline/internal/errorpage/errorpage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package errorpage

import (
"bytes"
_ "embed"
"html/template"
)

//go:embed error.go.tmpl
var defaultTemplate string

// defaultRenderer uses the embedded HTML template.
type defaultRenderer struct {
tmpl *template.Template
}

func (r *defaultRenderer) Render(data Data) ([]byte, error) {
var buf bytes.Buffer
if err := r.tmpl.Execute(&buf, data); err != nil {
return nil, err
}

return buf.Bytes(), nil
}

// NewRenderer returns a [Renderer] that uses the default embedded error page template.
// Panics if the template fails to parse (should never happen with an embedded template).
func NewRenderer() Renderer {
return &defaultRenderer{
tmpl: template.Must(template.New("error").Parse(defaultTemplate)),
}
}
27 changes: 27 additions & 0 deletions svc/frontline/internal/errorpage/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package errorpage

// Data contains all the fields available to the error page template.
type Data struct {
// StatusCode is the HTTP status code (e.g. 401, 502).
StatusCode int

// Title is the human-readable status text (e.g. "Unauthorized").
Title string

// Message is a longer explanation shown to the user.
Message string

// ErrorCode is the URN-style error code (e.g. "err:sentinel:unauthorized:invalid_key").
ErrorCode string

// DocsURL links to documentation for this error code. Empty if unavailable.
DocsURL string

// RequestID is the frontline request ID for support reference.
RequestID string
}

// Renderer renders an HTML error page from [Data].
type Renderer interface {
Render(data Data) ([]byte, error)
}
1 change: 1 addition & 0 deletions svc/frontline/middleware/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ go_library(
"//pkg/logger",
"//pkg/otel/tracing",
"//pkg/zen",
"//svc/frontline/internal/errorpage",
"@com_github_prometheus_client_golang//prometheus",
"@com_github_prometheus_client_golang//prometheus/promauto",
"@io_opentelemetry_go_otel//attribute",
Expand Down
51 changes: 21 additions & 30 deletions svc/frontline/middleware/observability.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package middleware

import (
"context"
"fmt"
"html"
"net/http"
"strconv"
"strings"
Expand All @@ -16,6 +14,7 @@ import (
"github.com/unkeyed/unkey/pkg/logger"
"github.com/unkeyed/unkey/pkg/otel/tracing"
"github.com/unkeyed/unkey/pkg/zen"
"github.com/unkeyed/unkey/svc/frontline/internal/errorpage"
"go.opentelemetry.io/otel/attribute"
)

Expand Down Expand Up @@ -102,7 +101,7 @@ func categorizeErrorTypeFrontline(urn codes.URN, statusCode int, hasError bool)
return "unknown"
}

func WithObservability(region string) zen.Middleware {
func WithObservability(region string, renderer errorpage.Renderer) zen.Middleware {
return func(next zen.HandleFunc) zen.HandleFunc {
return func(ctx context.Context, s *zen.Session) error {
startTime := time.Now()
Expand Down Expand Up @@ -180,7 +179,25 @@ func WithObservability(region string) zen.Middleware {
},
})
} else {
writeErr = s.HTML(pageInfo.Status, renderErrorHTMLFrontline(title, userMessage, string(code.URN())))
htmlBody, renderErr := renderer.Render(errorpage.Data{
StatusCode: pageInfo.Status,
Title: title,
Message: userMessage,
ErrorCode: string(code.URN()),
DocsURL: code.DocsURL(),
RequestID: s.RequestID(),
})
if renderErr != nil {
logger.Error("failed to render error page", "error", renderErr.Error())
writeErr = s.JSON(pageInfo.Status, ErrorResponse{
Error: ErrorDetail{
Code: string(code.URN()),
Message: userMessage,
},
})
} else {
writeErr = s.HTML(pageInfo.Status, htmlBody)
}
}

if writeErr != nil {
Expand Down Expand Up @@ -256,29 +273,3 @@ func getErrorPageInfoFrontline(urn codes.URN) errorPageInfo {
}
}
}

func renderErrorHTMLFrontline(title, message, errorCode string) []byte {
escapedTitle := html.EscapeString(title)
escapedMessage := html.EscapeString(message)
escapedErrorCode := html.EscapeString(errorCode)

return fmt.Appendf(nil, `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 100px auto; padding: 20px; }
h1 { color: #333; }
p { color: #666; line-height: 1.6; }
.error-code { color: #999; font-size: 0.9em; margin-top: 20px; }
</style>
</head>
<body>
<h1>%s</h1>
<p>%s</p>
<p class="error-code">Error: %s</p>
</body>
</html>`, escapedTitle, escapedTitle, escapedMessage, escapedErrorCode)
}
1 change: 1 addition & 0 deletions svc/frontline/routes/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions svc/frontline/routes/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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,
Expand Down
Loading