Skip to content

Commit ae07fb9

Browse files
authored
Merge pull request #205 from datum-cloud/feat/branded-error-pages
2 parents de24148 + ba366e2 commit ae07fb9

12 files changed

Lines changed: 666 additions & 6 deletions

File tree

config/extension-server/deployment.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ spec:
123123
- name: tls-ca
124124
mountPath: /tls-ca
125125
readOnly: true
126+
# Branded data-plane error pages. Backed by an OPTIONAL ConfigMap so
127+
# the pod starts even when the ConfigMap is absent — the extension
128+
# server then serves the page compiled into the operator image.
129+
# The operator config's gateway.errorPage.bodyPath points here.
130+
- name: error-pages
131+
mountPath: /etc/datum/error-pages
132+
readOnly: true
126133
terminationGracePeriodSeconds: 20
127134
volumes:
128135
# issuer-name and dns-names are placeholders — an overlay must patch them.
@@ -144,3 +151,14 @@ spec:
144151
items:
145152
- key: ca.crt
146153
path: ca.crt
154+
# Optional branded error-page content. The ConfigMap is created in the
155+
# infrastructure (GitOps) repo and carries the key error-5xx.html. With
156+
# optional: true the pod starts cleanly when it is absent, in which case
157+
# the extension server serves the embedded default page.
158+
- name: error-pages
159+
configMap:
160+
name: envoy-error-pages
161+
optional: true
162+
items:
163+
- key: error-5xx.html
164+
path: error-5xx.html

config/manager/config.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,18 @@ gateway:
1313
#
1414
# Default: false. Set to true in environments where dns-operator is deployed.
1515
enableDNSIntegration: false
16+
# errorPage configures the branded data-plane error page served by the
17+
# extension server for edge-generated 5xx responses on the downstream /
18+
# Connector data plane (e.g. an offline Connector tunnel). When enabled, the
19+
# extension server attaches an Envoy local_reply_config to every customer-
20+
# facing HCM so visitors see a branded "temporarily unavailable" page instead
21+
# of a raw body like "no healthy upstream".
22+
#
23+
# bodyPath points at the optional ConfigMap mount (volume "error-pages" in
24+
# config/extension-server/deployment.yaml). When the file is absent or empty,
25+
# the page compiled into the operator image is used as the fallback — content
26+
# problems never block xDS or fail startup.
27+
errorPage:
28+
enabled: true
29+
bodyPath: /etc/datum/error-pages/error-5xx.html
30+
minStatusCode: 500

internal/config/config.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,11 @@ type GatewayConfig struct {
633633
// Coraza specifies configuration for the Coraza WAF.
634634
Coraza CorazaConfig `json:"coraza,omitempty"`
635635

636+
// ErrorPage specifies configuration for the branded data-plane error page
637+
// served for edge-generated 5xx responses on the downstream / Connector
638+
// data plane.
639+
ErrorPage ErrorPageConfig `json:"errorPage,omitempty"`
640+
636641
// ValidPortNumbers is a list of port numbers that are permitted on gateway
637642
// listeners.
638643
//
@@ -790,6 +795,45 @@ type CorazaConfig struct {
790795

791796
// +k8s:deepcopy-gen=true
792797

798+
// ErrorPageConfig configures the branded data-plane error page. When enabled,
799+
// the extension server attaches an Envoy local_reply_config to every
800+
// customer-facing HCM so edge-generated 5xx responses render a branded HTML
801+
// page instead of a raw body like "no healthy upstream".
802+
//
803+
// The page content is sourced from BodyPath (a mounted ConfigMap) when present
804+
// and readable, otherwise from the page compiled into the operator image. A
805+
// missing or unreadable override never fails startup and never blocks xDS — it
806+
// falls back to the embedded default.
807+
type ErrorPageConfig struct {
808+
// Enabled toggles branded error-page injection. Defaults to false; the
809+
// extension server only attaches local_reply_config when this is true.
810+
Enabled bool `json:"enabled,omitempty"`
811+
812+
// BodyPath is an optional path to a file (typically a mounted ConfigMap key)
813+
// containing the branded HTML. When empty, unreadable, or empty-on-disk, the
814+
// embedded default page is used instead.
815+
BodyPath string `json:"bodyPath,omitempty"`
816+
817+
// MinStatusCode is the inclusive lower bound for response status codes that
818+
// receive the branded body. The original status code is always preserved.
819+
//
820+
// +default=500
821+
MinStatusCode uint32 `json:"minStatusCode,omitempty"`
822+
823+
// RuntimeKey is the Envoy runtime key gating the branded reply, allowing it
824+
// to be disabled at runtime without a redeploy.
825+
//
826+
// +default="local_reply_5xx"
827+
RuntimeKey string `json:"runtimeKey,omitempty"`
828+
829+
// ContentType is the Content-Type set on the branded response body.
830+
//
831+
// +default="text/html; charset=UTF-8"
832+
ContentType string `json:"contentType,omitempty"`
833+
}
834+
835+
// +k8s:deepcopy-gen=true
836+
793837
type ExtensionAPIValidationOptions struct {
794838
// BackendTrafficPolicies specifies validation options for BackendTrafficPolicy resources.
795839
BackendTrafficPolicies BackendTrafficPolicyValidationOptions `json:"backendTrafficPolicies"`

internal/config/zz_generated.deepcopy.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/config/zz_generated.defaults.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Package assets holds static content compiled into the extension server.
2+
//
3+
// The default branded 5xx error page is embedded here so the extension server
4+
// always has a valid page to serve, even when no override ConfigMap is mounted.
5+
// The canonical content is expected to come from a mounted ConfigMap (managed in
6+
// the infrastructure GitOps repo); this embed is the always-valid fallback.
7+
package assets
8+
9+
import _ "embed"
10+
11+
// DefaultError5xxHTML is the compiled-in branded HTML served for edge-generated
12+
// 5xx responses when no override page is mounted. Keep it small and
13+
// self-contained (inline CSS, no external assets) so it renders standalone.
14+
//
15+
//go:embed error-5xx-default.html
16+
var DefaultError5xxHTML string
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="robots" content="noindex" />
7+
<title>Temporarily unavailable</title>
8+
<style>
9+
:root { color-scheme: dark; }
10+
* { box-sizing: border-box; }
11+
html, body { height: 100%; margin: 0; }
12+
body {
13+
display: flex;
14+
align-items: center;
15+
justify-content: center;
16+
min-height: 100vh;
17+
padding: 1.5rem;
18+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
19+
background: radial-gradient(120% 120% at 50% 0%, #14151a 0%, #0a0b0d 60%, #060708 100%);
20+
color: #e8eaed;
21+
}
22+
.card {
23+
width: 100%;
24+
max-width: 30rem;
25+
text-align: center;
26+
background: rgba(255, 255, 255, 0.03);
27+
border: 1px solid rgba(255, 255, 255, 0.08);
28+
border-radius: 16px;
29+
padding: 2.5rem 2rem;
30+
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 20px 60px rgba(0, 0, 0, 0.45);
31+
}
32+
.badge {
33+
display: inline-block;
34+
font-size: 0.72rem;
35+
letter-spacing: 0.18em;
36+
text-transform: uppercase;
37+
color: #9aa3b2;
38+
margin-bottom: 1.25rem;
39+
}
40+
.dot {
41+
display: inline-block;
42+
width: 0.5rem;
43+
height: 0.5rem;
44+
border-radius: 50%;
45+
background: #f0a23c;
46+
margin-right: 0.5rem;
47+
vertical-align: middle;
48+
box-shadow: 0 0 12px rgba(240, 162, 60, 0.7);
49+
}
50+
h1 {
51+
font-size: 1.5rem;
52+
line-height: 1.3;
53+
margin: 0 0 0.75rem;
54+
font-weight: 600;
55+
}
56+
p {
57+
margin: 0 auto;
58+
max-width: 24rem;
59+
color: #aab2c0;
60+
font-size: 0.95rem;
61+
line-height: 1.55;
62+
}
63+
.meta {
64+
margin-top: 1.75rem;
65+
font-size: 0.78rem;
66+
color: #6b7280;
67+
}
68+
.code {
69+
font-variant-numeric: tabular-nums;
70+
color: #8b93a1;
71+
}
72+
</style>
73+
</head>
74+
<body>
75+
<main class="card">
76+
<span class="badge"><span class="dot"></span>Datum Cloud</span>
77+
<h1>This site is temporarily unavailable</h1>
78+
<p>
79+
The site you're trying to reach can't be served right now. This is usually
80+
temporary &mdash; please try again in a few moments.
81+
</p>
82+
<div class="meta">Error <span class="code">%RESPONSE_CODE%</span> &middot; served by the Datum edge</div>
83+
</main>
84+
</body>
85+
</html>

internal/extensionserver/cmd/run.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
3737
networkingv1alpha1 "go.datum.net/network-services-operator/api/v1alpha1"
3838
"go.datum.net/network-services-operator/internal/config"
39+
extassets "go.datum.net/network-services-operator/internal/extensionserver/assets"
3940
extcache "go.datum.net/network-services-operator/internal/extensionserver/cache"
4041
extmetrics "go.datum.net/network-services-operator/internal/extensionserver/metrics"
4142
"go.datum.net/network-services-operator/internal/extensionserver/mutate"
@@ -110,6 +111,44 @@ func NewCommand() *cobra.Command {
110111
return cmd
111112
}
112113

114+
// buildLocalReplyConfig assembles the branded error-page configuration for the
115+
// extension server from the operator's ErrorPageConfig.
116+
//
117+
// Content sourcing is fail-safe by design: it starts from the page compiled
118+
// into the operator image and, only if ErrorPage.BodyPath points at a readable
119+
// non-empty file, swaps in that override. A missing, unreadable, or empty
120+
// override logs a warning and keeps the embedded default — it NEVER fails
121+
// startup. This guarantees the downstream EG extension hook (failOpen:false)
122+
// always has a valid body to inject and is never stalled by a content problem.
123+
//
124+
// Injection itself is gated on ErrorPage.Enabled via the Disabled flag.
125+
func buildLocalReplyConfig(cfg config.ErrorPageConfig, log *slog.Logger) mutate.LocalReplyConfig {
126+
body := extassets.DefaultError5xxHTML
127+
128+
if cfg.BodyPath != "" {
129+
data, err := os.ReadFile(cfg.BodyPath)
130+
switch {
131+
case err != nil:
132+
log.Warn("read error-page body override; using embedded default",
133+
"path", cfg.BodyPath, "err", err)
134+
case len(data) == 0:
135+
log.Warn("error-page body override is empty; using embedded default",
136+
"path", cfg.BodyPath)
137+
default:
138+
body = string(data)
139+
log.Info("using error-page body override", "path", cfg.BodyPath, "bytes", len(data))
140+
}
141+
}
142+
143+
return mutate.LocalReplyConfig{
144+
Disabled: !cfg.Enabled,
145+
MinStatusCode: cfg.MinStatusCode,
146+
RuntimeKey: cfg.RuntimeKey,
147+
BodyHTML: body,
148+
ContentType: cfg.ContentType,
149+
}
150+
}
151+
113152
// run starts the extension server with the given options. It owns cache startup,
114153
// gRPC server lifecycle, and signal handling.
115154
func run(o options) {
@@ -205,6 +244,7 @@ func run(o options) {
205244
},
206245
ConnectorInternalListener: serverConfig.Gateway.ConnectorTunnelListenerName(),
207246
CorazaRouteBaseDirectives: coraza.RouteBaseDirectives,
247+
LocalReply: buildLocalReplyConfig(serverConfig.Gateway.ErrorPage, log),
208248
}
209249

210250
// --- gRPC panic recovery interceptor ---

internal/extensionserver/metrics/metrics.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ var (
101101
},
102102
)
103103

104+
// LocalReplyMutationsTotal counts total branded error-page (local_reply_config)
105+
// injections across all hook invocations. Each increment = one HCM that had a
106+
// branded local_reply_config attached. Use rate()/sum() to derive per-build
107+
// averages.
108+
LocalReplyMutationsTotal = promauto.NewCounter(
109+
prometheus.CounterOpts{
110+
Name: "nso_extension_local_reply_mutations_total",
111+
Help: "Total branded error-page (local_reply_config) injections across all hook invocations.",
112+
},
113+
)
114+
104115
// WAFRouteMutationsTotal counts total per-route TrafficProtectionPolicy (WAF)
105116
// config applications across all hook invocations.
106117
WAFRouteMutationsTotal = promauto.NewCounter(

0 commit comments

Comments
 (0)