feat(option): add sw offline

fix(openresty): scope sw injection per cert partition

fix(lint): satisfy revive and biome format for sw offline feature

docs: sw offline scope changelog

fix(frontend): use scoped query key for sw scope zones

fix(frontend): hide preview link in sw contact page editor

feat(frontend): add sw scope domain picker and contact page fields

refactor(frontend): generalize html editor workspace for reuse

feat(openresty): scope sw offline injection by route domains

feat(openresty): add sw offline domains snapshot field

feat(option): add sw offline domains scope option

docs: fill html editor workspace generalization detail

docs: sw offline scope implementation plan

docs: sw offline scope design

test(openresty): assert single merged access block in sw enabled servers

fix(openresty): restrict sw intercept to https server blocks

fix(openresty): version sw offline cache by html content

fix(agent): escape redir in sw challenge page to prevent xss

fix(agent): return sw.runtime module table and add lua spec

docs: sw offline fallback changelog

fix(frontend): memoize option map to preserve unsaved contact page edits

feat(frontend): add response pages module with contact page tab

feat(agent): ship sw offline lua assets and placeholder substitution

feat(config): wire sw offline options into config snapshot

feat(openresty): render sw offline assets and challenge intercept

feat(openresty): add sw offline ConfigSnapshot fields and placeholder

feat(db): seed sw offline options

feat(option): add sw offline config keys and validation

docs: add service worker offline fallback implementation plan

docs: adopt global-option pattern for SW offline fallback (matches origin error page)

docs: unify offline contact page with error pages as response pages

docs: service worker offline fallback design (issue #23)
This commit is contained in:
ryan
2026-08-08 12:22:21 +08:00
parent 734fe45baa
commit ca21ff3a5b
33 changed files with 4021 additions and 40 deletions
+49 -4
View File
@@ -49,6 +49,9 @@ func Render(doc Document, certificateFiles []SupportFile) (*Result, error) {
if doc.OpenRestyConfig.OriginErrorPageEnabled {
files = append(files, originErrorPageSupportFile(doc.OpenRestyConfig))
}
if doc.OpenRestyConfig.SWOfflineEnabled && len(doc.OpenRestyConfig.SWOfflineDomains) > 0 {
files = append(files, ServiceWorkerSupportFiles(doc.OpenRestyConfig)...)
}
files = DedupeSupportFiles(files)
return &Result{
MainConfig: mainConfig,
@@ -272,7 +275,7 @@ func renderOpenRestyObservabilityTemplateBlock() string {
return fmt.Sprintf(" lua_shared_dict openflare_observability 10m;\n lua_shared_dict openflare_pow_challenges 10m;\n lua_shared_dict openflare_pow_sessions 10m;\n lua_shared_dict openflare_pow_config 1m;\n lua_shared_dict openflare_waf_config 1m;\n lua_shared_dict openflare_waf_ip_groups 64m;\n init_worker_by_lua_file %s/observability/init.lua;\n log_by_lua_file %s/observability/log.lua;\n\n server {\n listen %s;\n server_name openflare-observability;\n access_log off;\n\n location = /openflare/stub_status {\n stub_status;\n }\n\n location = /openflare/observability {\n default_type application/json;\n content_by_lua_file %s/observability/read.lua;\n }\n }\n\n", LuaDirPlaceholder, LuaDirPlaceholder, ObservabilityListenPlaceholder, LuaDirPlaceholder)
}
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
func renderHTTPProxyServer(serverNames string, siteName string, originURL string, originHost string, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, _ bool, cfg ConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
}
@@ -319,7 +322,7 @@ func renderPagesAPIProxyLocationBlock(deployment *PagesDeployment) string {
return builder.String()
}
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string) string {
func renderHTTPPagesServer(serverNames string, siteName string, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, _ bool, _ ConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", serverNames, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
}
@@ -327,7 +330,7 @@ func renderHTTPRedirectServer(serverNames string) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n return 301 https://$host$request_uri;\n}\n\n", serverNames)
}
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
func renderHTTPSServer(serverNames string, siteName string, originURL string, originHost string, certificateID uint, customHeaders []CustomHeader, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
var h3Listen string
@@ -336,10 +339,13 @@ func renderHTTPSServer(serverNames string, siteName string, originURL string, or
h3Listen = " listen 443 quic;\n"
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
}
if swEnabled {
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
}
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s location / {\n%s%s%s%s%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig, cfg), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderOriginErrorPageIntercept(cfg), renderProxyPassBlock(originURL, upstreamConfig), renderOriginErrorPageServerBits(cfg), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, cfg ConfigSnapshot) string {
func renderHTTPSPagesServer(serverNames string, siteName string, certificateID uint, deployment *PagesDeployment, limitConfig routeLimitConfig, powEnabled bool, basicAuthEnabled bool, basicAuthUsername string, basicAuthPassword string, swEnabled bool, cfg ConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%d.crt", CertDirPlaceholder, certificateID)
keyPath := fmt.Sprintf("%s/%d.key", CertDirPlaceholder, certificateID)
var h3Listen string
@@ -348,6 +354,9 @@ func renderHTTPSPagesServer(serverNames string, siteName string, certificateID u
h3Listen = " listen 443 quic;\n"
h3Header = " add_header Alt-Svc 'h3=\":443\"; ma=86400';\n"
}
if swEnabled {
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlockWithSW(siteName, powEnabled, cfg), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled), renderServiceWorkerChallenger(cfg))
}
return fmt.Sprintf("server {\n listen 443 ssl;\n%s http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s%s%s root %s;\n index %s;%s%s\n\n location / {\n%s%s }\n%s}\n\n", h3Listen, serverNames, certPath, keyPath, h3Header, renderAccessBlock(siteName, powEnabled), renderPowLocationBlocks(powEnabled), quoteNginxStringLiteral(pagesDeploymentRoot(deployment)), quoteNginxStringLiteral(pagesEntryFile(deployment)), renderPagesAPIProxyLocationBlock(deployment), renderPagesRootLocationBlock(deployment, limitConfig, basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderBasicAuthBlock(basicAuthEnabled, basicAuthUsername, basicAuthPassword), renderPagesLocationBlock(deployment, limitConfig), renderPowStaticLocationBlock(powEnabled))
}
@@ -464,6 +473,42 @@ func renderAccessBlock(siteName string, powEnabled bool) string {
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
}
// renderAccessBlockWithSW emits the access phase directives for a server block,
// merging the Service Worker runtime check into the single access directive.
// nginx runs only the last access_by_lua* directive in a scope, so the SW check
// must never be emitted as a second directive; otherwise it would silently
// override (or be overridden by) the WAF/PoW check.
func renderAccessBlockWithSW(siteName string, powEnabled bool, _ ConfigSnapshot) string {
escapedSiteName := escapeNginxString(siteName)
if !powEnabled {
return fmt.Sprintf(` set $openflare_waf_site "%s";
access_by_lua_block {
if not string.find(package.path, "%s/?.lua", 1, true) then
package.path = "%s/?.lua;%s/?/init.lua;" .. package.path
end
require("waf.runtime").check()
if ngx.ctx.openflare_waf_blocked then
return
end
require("sw.runtime").check()
}
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
}
return fmt.Sprintf(` set $openflare_waf_site "%s";
access_by_lua_block {
if not string.find(package.path, "%s/?.lua", 1, true) then
package.path = "%s/?.lua;%s/?/init.lua;" .. package.path
end
require("waf.runtime").check()
if ngx.ctx.openflare_waf_blocked then
return
end
require("pow.runtime").check()
require("sw.runtime").check()
}
`, escapedSiteName, LuaDirPlaceholder, LuaDirPlaceholder, LuaDirPlaceholder)
}
func renderBasicAuthBlock(enabled bool, username, password string) string {
if !enabled || username == "" || password == "" {
return ""
+8 -8
View File
@@ -55,7 +55,7 @@ func renderPagesRouteHTTPS(
) {
if route.RedirectHTTP {
if len(partition.httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPPagesServer(renderServerNames(partition.httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
builder.WriteString(renderHTTPPagesServer(renderServerNames(partition.httpOnlyDomains), displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
}
for _, certID := range certIDs {
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
@@ -63,11 +63,11 @@ func renderPagesRouteHTTPS(
}
}
} else {
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
}
for _, certID := range certIDs {
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
builder.WriteString(renderHTTPSPagesServer(renderServerNames(assignedDomains), displayName, certID, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
}
}
}
@@ -86,7 +86,7 @@ func renderProxyRouteHTTPS(
) {
if route.RedirectHTTP {
if len(partition.httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPProxyServer(renderServerNames(partition.httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
builder.WriteString(renderHTTPProxyServer(renderServerNames(partition.httpOnlyDomains), displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
}
for _, certID := range certIDs {
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
@@ -94,11 +94,11 @@ func renderProxyRouteHTTPS(
}
}
} else {
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
}
for _, certID := range certIDs {
if assignedDomains := partition.domainsByCertID[certID]; len(assignedDomains) > 0 {
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), displayName, route.OriginURL, route.OriginHost, certID, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, routeSWEnabled(assignedDomains, cfg), cfg))
}
}
}
@@ -108,7 +108,7 @@ func renderPagesRoute(builder *strings.Builder, route Route, displayName, server
return fmt.Errorf("route %s pages deployment is missing", route.SiteName)
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword))
builder.WriteString(renderHTTPPagesServer(serverNames, displayName, route.PagesDeployment, limitConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
return nil
}
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
@@ -134,7 +134,7 @@ func renderProxyRoute(builder *strings.Builder, route Route, displayName, server
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, displayName, route.OriginURL, route.OriginHost, route.CustomHeaders, cacheConfig, limitConfig, upstreamConfig, powEnabled, route.BasicAuthEnabled, route.BasicAuthUsername, route.BasicAuthPassword, false, cfg))
return nil
}
certIDs := certificateIDsFromDomainCertIDs(route.DomainCertIDs)
+133
View File
@@ -0,0 +1,133 @@
package openresty
import (
"crypto/sha256"
"encoding/hex"
"strings"
)
// SW location strings and Lua module paths used by the Service Worker offline fallback.
const (
SWJSLocation = "location = /sw.js"
SWOfflineLocation = "location = /offline.html"
SWChallengeLua = "sw/challenge.lua"
SWRuntimeLua = "sw/runtime.lua"
swDirPrefix = "sw/"
)
// DefaultSWOfflineHTML is the built-in contact page shown when the domain is blocked.
const DefaultSWOfflineHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网站暂时无法访问 | 联系站长</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #ffffff; color: #333333; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 48px 24px; }
h1 { font-size: 28px; font-weight: 700; margin-bottom: 16px; }
p { font-size: 16px; line-height: 1.7; color: #666666; max-width: 520px; }
</style>
</head>
<body>
<h1>网站暂时无法访问</h1>
<p>当前域名暂时无法从网络访问。请通过其他方式联系网站管理员获取最新访问入口。</p>
</body>
</html>
`
// EffectiveSWOfflineHTML returns custom HTML when set, otherwise the built-in default.
func EffectiveSWOfflineHTML(cfg ConfigSnapshot) string {
if strings.TrimSpace(cfg.SWOfflineHTML) == "" {
return DefaultSWOfflineHTML
}
return cfg.SWOfflineHTML
}
// ServiceWorkerSupportFiles returns the sw.js script and offline contact page.
// The sw.js content is derived from the offline HTML (see defaultSWJS) so that
// HTML-only edits change the script, forcing browsers to re-install the worker
// and re-cache the updated page.
func ServiceWorkerSupportFiles(cfg ConfigSnapshot) []SupportFile {
if !cfg.SWOfflineEnabled {
return nil
}
html := EffectiveSWOfflineHTML(cfg)
return []SupportFile{
{Path: swDirPrefix + "sw.js", Content: defaultSWJS(html)},
{Path: swDirPrefix + "offline.html", Content: html},
}
}
// swJSTemplate is the service worker body. The cache name is replaced with a
// version derived from the offline HTML: editing the HTML changes the cache
// name, which changes the sw.js bytes, which makes the browser re-install the
// worker (sw.js is served with Cache-Control: no-cache) and fetch the new
// /offline.html into the fresh cache during install.
const swJSTemplate = `var CACHE = "__CACHE_NAME__";
var OFFLINE = "/offline.html";
self.addEventListener("install", function (e) {
e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll([OFFLINE]); }));
self.skipWaiting();
});
self.addEventListener("activate", function (e) {
e.waitUntil(caches.keys().then(function (keys) {
return Promise.all(keys.filter(function (k) { return k.indexOf("openflare-offline-") === 0 && k !== CACHE; }).map(function (k) { return caches.delete(k); }));
}));
self.clients.claim();
});
self.addEventListener("fetch", function (e) {
if (e.request.method !== "GET" || e.request.mode !== "navigate") { return; }
e.respondWith(
fetch(e.request).catch(function () {
return caches.match(e.request).then(function (r) { return r || caches.match(OFFLINE); });
})
);
});
`
func defaultSWJS(offlineHTML string) string {
sum := sha256.Sum256([]byte(offlineHTML))
version := hex.EncodeToString(sum[:])[:12]
return strings.ReplaceAll(swJSTemplate, "__CACHE_NAME__", "openflare-offline-"+version)
}
// routeSWEnabled returns true when SW offline fallback applies to this route.
func routeSWEnabled(routeDomains []string, cfg ConfigSnapshot) bool {
if !cfg.SWOfflineEnabled || len(cfg.SWOfflineDomains) == 0 {
return false
}
scope := make(map[string]struct{}, len(cfg.SWOfflineDomains))
for _, d := range cfg.SWOfflineDomains {
scope[d] = struct{}{}
}
for _, d := range routeDomains {
if _, ok := scope[d]; ok {
return true
}
}
return false
}
// renderServiceWorkerChallenger emits SW static locations and the homepage
// challenge intercept for HTTPS server blocks.
func renderServiceWorkerChallenger(_ ConfigSnapshot) string {
var builder strings.Builder
builder.WriteString("\n location = /sw.js {\n")
builder.WriteString(" alias " + SWDirPlaceholder + "/sw.js;\n")
builder.WriteString(" default_type application/javascript;\n")
builder.WriteString(" add_header Service-Worker-Allowed /;\n")
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
builder.WriteString(" }\n\n")
builder.WriteString(" location = /offline.html {\n")
builder.WriteString(" alias " + SWDirPlaceholder + "/offline.html;\n")
builder.WriteString(" default_type text/html;\n")
builder.WriteString(" add_header Cache-Control \"no-cache\";\n")
builder.WriteString(" }\n\n")
builder.WriteString(" location = /__openflare_sw_challenge {\n")
builder.WriteString(" internal;\n")
builder.WriteString(" # hit when sw.runtime.check() intercepts the homepage in the access phase\n")
builder.WriteString(" content_by_lua_file " + SWDirPlaceholder + "/challenge.lua;\n")
builder.WriteString(" }\n")
return builder.String()
}
+282
View File
@@ -0,0 +1,282 @@
package openresty
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"strings"
"testing"
"time"
)
func TestEffectiveSWOfflineHTML(t *testing.T) {
if got := EffectiveSWOfflineHTML(ConfigSnapshot{}); got != DefaultSWOfflineHTML {
t.Fatalf("default mismatch")
}
custom := "<html>custom</html>"
if got := EffectiveSWOfflineHTML(ConfigSnapshot{SWOfflineHTML: custom}); got != custom {
t.Fatalf("custom mismatch")
}
}
func TestServiceWorkerSupportFiles(t *testing.T) {
disabled := ServiceWorkerSupportFiles(ConfigSnapshot{})
if disabled != nil {
t.Fatalf("expected nil when disabled, got %v", disabled)
}
enabled := ServiceWorkerSupportFiles(ConfigSnapshot{SWOfflineEnabled: true})
if len(enabled) != 2 {
t.Fatalf("expected 2 support files, got %d", len(enabled))
}
paths := map[string]string{}
for _, f := range enabled {
paths[f.Path] = f.Content
}
if _, ok := paths["sw/sw.js"]; !ok {
t.Fatalf("missing sw/sw.js")
}
if _, ok := paths["sw/offline.html"]; !ok {
t.Fatalf("missing sw/offline.html")
}
if paths["sw/offline.html"] != DefaultSWOfflineHTML {
t.Fatalf("expected built-in offline html, got %q", paths["sw/offline.html"])
}
if !strings.Contains(paths["sw/sw.js"], `var OFFLINE = "/offline.html";`) {
t.Fatalf("offline path must stay stable (exact location match), got:\n%s", paths["sw/sw.js"])
}
}
func TestDefaultSWJSCacheNameTracksOfflineHTML(t *testing.T) {
htmlA := "<html>page-a</html>"
htmlB := "<html>page-b</html>"
jsA := defaultSWJS(htmlA)
jsB := defaultSWJS(htmlB)
if jsA == jsB {
t.Fatal("sw.js content must change when the offline HTML changes")
}
extractCache := func(js string) string {
const prefix = `var CACHE = "`
start := strings.Index(js, prefix)
if start < 0 {
t.Fatalf("missing cache name in:\n%s", js)
}
rest := js[start+len(prefix):]
end := strings.Index(rest, `"`)
if end < 0 {
t.Fatalf("unterminated cache name in:\n%s", js)
}
return rest[:end]
}
cacheA := extractCache(jsA)
cacheB := extractCache(jsB)
if cacheA == cacheB {
t.Fatalf("cache names must differ per HTML, got %q", cacheA)
}
if !strings.HasPrefix(cacheA, "openflare-offline-") {
t.Fatalf("unexpected cache name %q", cacheA)
}
for _, js := range []string{jsA, jsB} {
if strings.Contains(js, "openflare-offline-v1") {
t.Fatalf("static cache name must not remain, got:\n%s", js)
}
if strings.Contains(js, "__CACHE_NAME__") {
t.Fatalf("template placeholder leaked into sw.js:\n%s", js)
}
}
// Same HTML must produce identical sw.js (deterministic checksum).
if defaultSWJS(htmlA) != jsA {
t.Fatal("sw.js must be deterministic for identical HTML")
}
}
func TestRenderAccessBlockWithSWMergesSingleBlock(t *testing.T) {
for _, powEnabled := range []bool{false, true} {
name := "pow-disabled"
if powEnabled {
name = "pow-enabled"
}
t.Run(name, func(t *testing.T) {
got := renderAccessBlockWithSW("example.com", powEnabled, ConfigSnapshot{})
if n := strings.Count(got, "access_by_lua_block"); n != 1 {
t.Fatalf("expected exactly 1 access_by_lua_block, got %d:\n%s", n, got)
}
if !strings.Contains(got, `require("sw.runtime").check()`) {
t.Fatalf("expected sw.runtime check, got:\n%s", got)
}
wafIdx := strings.Index(got, `require("waf.runtime").check()`)
swIdx := strings.Index(got, `require("sw.runtime").check()`)
if wafIdx < 0 || swIdx < 0 || wafIdx > swIdx {
t.Fatalf("expected waf.runtime before sw.runtime, got:\n%s", got)
}
if powEnabled {
powIdx := strings.Index(got, `require("pow.runtime").check()`)
if powIdx < 0 || wafIdx > powIdx || powIdx > swIdx {
t.Fatalf("expected waf.runtime before pow.runtime before sw.runtime, got:\n%s", got)
}
}
})
}
}
func TestRenderServiceWorkerChallengerHTTPExclusion(t *testing.T) {
cfg := ConfigSnapshot{SWOfflineEnabled: true}
for name, rendered := range map[string]string{
"proxy": renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg),
"pages": renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg),
"https": renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg),
"hpages": renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg),
} {
if strings.Contains(rendered, "access_by_lua_block") && strings.Count(rendered, "access_by_lua_block") != 1 {
t.Fatalf("%s: expected at most one access block, got:\n%s", name, rendered)
}
}
httpProxy := renderHTTPProxyServer("example.com", "example.com", "http://127.0.0.1:8080", "", nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, cfg)
if strings.Contains(httpProxy, "sw.runtime") || strings.Contains(httpProxy, "openflare_sw_challenge") || strings.Contains(httpProxy, "location = /sw.js") {
t.Fatalf("HTTP proxy server must not carry SW intercept, got:\n%s", httpProxy)
}
httpPages := renderHTTPPagesServer("example.com", "example.com", nil, routeLimitConfig{}, false, false, "", "", false, cfg)
if strings.Contains(httpPages, "sw.runtime") || strings.Contains(httpPages, "openflare_sw_challenge") || strings.Contains(httpPages, "location = /sw.js") {
t.Fatalf("HTTP pages server must not carry SW intercept, got:\n%s", httpPages)
}
httpsProxy := renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", true, cfg)
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if !strings.Contains(httpsProxy, want) {
t.Fatalf("HTTPS proxy server missing %q, got:\n%s", want, httpsProxy)
}
}
httpsPages := renderHTTPSPagesServer("example.com", "example.com", 1, nil, routeLimitConfig{}, false, false, "", "", true, cfg)
for _, want := range []string{"sw.runtime", "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if !strings.Contains(httpsPages, want) {
t.Fatalf("HTTPS pages server missing %q, got:\n%s", want, httpsPages)
}
}
}
func TestRouteSWEnabled(t *testing.T) {
cfgOff := ConfigSnapshot{SWOfflineEnabled: false, SWOfflineDomains: []string{"example.com"}}
if routeSWEnabled([]string{"example.com"}, cfgOff) {
t.Fatal("expected false when master switch off")
}
cfgEmpty := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: nil}
if routeSWEnabled([]string{"example.com"}, cfgEmpty) {
t.Fatal("expected false when scope empty")
}
cfgHit := ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com", "other.com"}}
if !routeSWEnabled([]string{"api.example.com", "example.com"}, cfgHit) {
t.Fatal("expected true on single domain intersection")
}
if routeSWEnabled([]string{"api.example.com", "third.com"}, cfgHit) {
t.Fatal("expected false on no intersection")
}
}
func TestRenderHTTPSServerSWScope(t *testing.T) {
render := func(swEnabled bool) string {
return renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", swEnabled, ConfigSnapshot{SWOfflineEnabled: true})
}
hit := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"example.com"}}))
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if !strings.Contains(hit, want) {
t.Fatalf("scoped HTTPS server missing %q, got:\n%s", want, hit)
}
}
miss := render(routeSWEnabled([]string{"example.com"}, ConfigSnapshot{SWOfflineEnabled: true, SWOfflineDomains: []string{"other.com"}}))
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if strings.Contains(miss, notWant) {
t.Fatalf("out-of-scope HTTPS server must not carry %q, got:\n%s", notWant, miss)
}
}
if miss != renderHTTPSServer("example.com", "example.com", "http://127.0.0.1:8080", "", 1, nil, routeCacheConfig{}, routeLimitConfig{}, routeUpstreamConfig{}, false, false, "", "", false, ConfigSnapshot{}) {
t.Fatalf("out-of-scope HTTPS server must match pre-feature bytes, got:\n%s", miss)
}
}
func TestRenderRouteConfigSWSCOPEPerCertPartition(t *testing.T) {
doc := Document{
OpenRestyConfig: ConfigSnapshot{
SWOfflineEnabled: true,
SWOfflineDomains: []string{"a.com"},
},
Routes: []Route{{
ID: 1,
SiteName: "multi.example.com",
Domains: []string{"a.com", "b.com"},
OriginURL: "http://127.0.0.1:8080",
EnableHTTPS: true,
DomainCertIDs: []uint{11, 22},
}},
}
certFiles := []SupportFile{
{Path: "11.crt", Content: testCertificatePEMForDomain(t, "a.com")},
{Path: "22.crt", Content: testCertificatePEMForDomain(t, "b.com")},
}
rendered, err := RenderRouteConfig(doc, certFiles)
if err != nil {
t.Fatalf("RenderRouteConfig() error = %v", err)
}
inScope := httpsServerBlockForCert(t, rendered, 11)
if !strings.Contains(inScope, "server_name a.com;") {
t.Fatalf("cert 11 block must serve a.com, got:\n%s", inScope)
}
for _, want := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if !strings.Contains(inScope, want) {
t.Fatalf("in-scope cert partition (a.com) missing %q, got:\n%s", want, inScope)
}
}
outOfScope := httpsServerBlockForCert(t, rendered, 22)
if !strings.Contains(outOfScope, "server_name b.com;") {
t.Fatalf("cert 22 block must serve b.com, got:\n%s", outOfScope)
}
for _, notWant := range []string{`require("sw.runtime").check()`, "location = /sw.js", "location = /offline.html", "__openflare_sw_challenge"} {
if strings.Contains(outOfScope, notWant) {
t.Fatalf("out-of-scope cert partition (b.com) must not carry %q, got:\n%s", notWant, outOfScope)
}
}
}
func httpsServerBlockForCert(t *testing.T, rendered string, certID uint) string {
t.Helper()
marker := fmt.Sprintf("ssl_certificate %s/%d.crt;", CertDirPlaceholder, certID)
for _, block := range strings.Split(rendered, "server {") {
if strings.Contains(block, marker) {
return "server {" + block
}
}
t.Fatalf("no server block found for cert %d in:\n%s", certID, rendered)
return ""
}
func testCertificatePEMForDomain(t *testing.T, domain string) string {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey() error = %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: domain},
DNSNames: []string{domain},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("x509.CreateCertificate() error = %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}
func TestRenderServiceWorkerChallenger(t *testing.T) {
got := renderServiceWorkerChallenger(ConfigSnapshot{SWOfflineEnabled: true})
for _, want := range []string{"location = /sw.js", "location = /offline.html", "challenge.lua", "content_by_lua"} {
if !strings.Contains(got, want) {
t.Fatalf("challenger missing %q", want)
}
}
}
+7
View File
@@ -21,6 +21,7 @@ const (
PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
PagesDirPlaceholder = "__OPENFLARE_PAGES_DIR__"
ErrorPageTmplPlaceholder = "__OPENFLARE_ERROR_PAGE_TMPL__"
SWDirPlaceholder = "__OPENFLARE_SW_DIR__"
SourceConfigFileName = "openresty_config.json"
)
@@ -320,6 +321,12 @@ type ConfigSnapshot struct {
OriginErrorPageHTML string `json:"origin_error_page_html,omitempty"`
// OriginErrorPageGetOnly limits custom error HTML to GET requests; other methods pass through.
OriginErrorPageGetOnly bool `json:"origin_error_page_get_only,omitempty"`
// SWOfflineEnabled enables the Service Worker offline fallback for HTTPS routes.
SWOfflineEnabled bool `json:"sw_offline_enabled,omitempty"`
// SWOfflineHTML is the contact-page HTML served offline; empty uses the built-in default.
SWOfflineHTML string `json:"sw_offline_html,omitempty"`
// SWOfflineDomains restricts the offline fallback to matching HTTPS routes.
SWOfflineDomains []string `json:"sw_offline_domains,omitempty"`
}
// Document is the top-level input structure for the OpenResty renderer,