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
+20
View File
@@ -401,6 +401,7 @@ func (m *Manager) EnsureLuaAssets() error {
return fmt.Errorf("load pow static files: %w", err)
}
allSupportFiles = append(allSupportFiles, powStaticFiles...)
allSupportFiles = append(allSupportFiles, ManagedSWLuaFiles()...)
files := make([]managedFile, 0, len(allSupportFiles))
for _, file := range allSupportFiles {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
@@ -525,6 +526,8 @@ func (m *Manager) CurrentChecksum() (string, error) {
// Longer error-page path must be restored before the cert-dir prefix rewrite.
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
normalizedRoute = strings.ReplaceAll(normalizedRoute, errorPagePath, openrestyrender.ErrorPageTmplPlaceholder)
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
normalizedRoute = strings.ReplaceAll(normalizedRoute, swDir, openrestyrender.SWDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, openrestyrender.CertDirPlaceholder)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
@@ -1022,6 +1025,7 @@ func (m *Manager) writeSourceConfig(supportFiles []protocol.SupportFile) error {
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
certFiles = append(certFiles, ManagedSWLuaFiles()...)
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
if file.Path == powConfigFileName || file.Path == "waf_config.json" || file.Path == openrestyrender.SourceConfigFileName {
@@ -1115,6 +1119,20 @@ func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
if err != nil {
return nil, err
}
// Agent-shipped SW Lua assets are not part of the rendered bundle, so they
// must stay out of the bundle checksum to keep it aligned with the server.
swManaged := make(map[string]struct{}, len(ManagedSWLuaFiles()))
for _, file := range ManagedSWLuaFiles() {
swManaged[file.Path] = struct{}{}
}
kept := files[:0]
for _, file := range files {
if _, skip := swManaged[file.Path]; skip {
continue
}
kept = append(kept, file)
}
files = kept
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
@@ -1380,6 +1398,8 @@ func (m *Manager) renderRouteConfig(content string) string {
rendered = strings.ReplaceAll(rendered, openrestyrender.CertDirPlaceholder, m.NginxCertDir)
errorPagePath := filepath.ToSlash(filepath.Join(m.NginxCertDir, openrestyrender.OriginErrorPageSupportPath))
rendered = strings.ReplaceAll(rendered, openrestyrender.ErrorPageTmplPlaceholder, errorPagePath)
swDir := filepath.ToSlash(filepath.Join(m.NginxCertDir, "sw"))
rendered = strings.ReplaceAll(rendered, openrestyrender.SWDirPlaceholder, swDir)
}
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, openrestyrender.LuaDirPlaceholder, luaDir)
+79
View File
@@ -337,6 +337,85 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
}
}
func TestManagerApplyShipsSWAssetsAndReplacesSWDirPlaceholder(t *testing.T) {
tempDir := t.TempDir()
manager := &Manager{
MainConfigPath: filepath.Join(tempDir, "nginx.conf"),
RouteConfigPath: filepath.Join(tempDir, "routes.conf"),
CertDir: filepath.Join(tempDir, "certs"),
NginxCertDir: "/etc/nginx/openflare-certs",
LuaDir: filepath.Join(tempDir, "lua"),
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{},
}
outcome := manager.Apply(
context.Background(),
"include __OPENFLARE_ROUTE_CONFIG__;",
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
[]protocol.SupportFile{
{Path: "sw/sw.js", Content: "js"},
{Path: "sw/offline.html", Content: "html"},
},
)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
routeData, err := os.ReadFile(manager.RouteConfigPath)
if err != nil {
t.Fatalf("failed to read route config: %v", err)
}
rendered := string(routeData)
for _, want := range []string{
"/etc/nginx/openflare-certs/sw/sw.js",
"/etc/nginx/openflare-certs/sw/offline.html",
"/etc/nginx/openflare-certs/sw/challenge.lua",
} {
if !strings.Contains(rendered, want) {
t.Fatalf("route config missing %q, got %s", want, rendered)
}
}
if strings.Contains(rendered, openrestyrender.SWDirPlaceholder) {
t.Fatalf("route config still contains SW dir placeholder: %s", rendered)
}
for _, path := range []string{
filepath.Join(manager.CertDir, "sw", "sw.js"),
filepath.Join(manager.CertDir, "sw", "offline.html"),
filepath.Join(manager.CertDir, "sw", "challenge.lua"),
filepath.Join(manager.CertDir, "sw", "runtime.lua"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected SW asset %s to exist: %v", path, err)
}
}
for _, path := range []string{
filepath.Join(manager.LuaDir, "sw", "challenge.lua"),
filepath.Join(manager.LuaDir, "sw", "runtime.lua"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected SW lua asset %s to exist: %v", path, err)
}
}
checksum, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"include __OPENFLARE_ROUTE_CONFIG__;",
"alias __OPENFLARE_SW_DIR__/sw.js;\nalias __OPENFLARE_SW_DIR__/offline.html;\ncontent_by_lua_file __OPENFLARE_SW_DIR__/challenge.lua;\nrequire(\"__OPENFLARE_LUA_DIR__\")",
[]protocol.SupportFile{
{Path: "sw/sw.js", Content: "js"},
{Path: "sw/offline.html", Content: "html"},
},
)
if checksum != expected {
t.Fatalf("unexpected checksum: got %s want %s", checksum, expected)
}
}
func TestManagerRenderMainConfigInitializesWAFRuntimeInWorker(t *testing.T) {
manager := &Manager{NginxLuaDir: "/etc/nginx/openflare-lua"}
rendered := manager.renderMainConfig("events {}\nhttp {\n lua_shared_dict openflare_waf_config 1m;\n server {}\n}\n")
+104
View File
@@ -0,0 +1,104 @@
package nginx
import (
"github.com/Rain-kl/Wavelet/internal/apps/agent/protocol"
)
const openRestySWRuntimeLua = `local _M = {}
local source = debug.getinfo(1, "S").source or ""
if string.sub(source, 1, 1) == "@" then
local script_path = string.sub(source, 2)
local base_dir = string.match(script_path, "^(.*)/sw/[^/]+%.lua$")
if base_dir and base_dir ~= "" and not string.find(package.path, base_dir, 1, true) then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local function is_real_browser(ua)
if not ua or ua == "" then return false end
-- Chrome/Edge/CentOS-style: "Chrome/120" (pattern mode: %d = digit)
if string.find(ua, "Chrome/%d", 1) then return true end
-- Firefox: "Firefox/120"
if string.find(ua, "Firefox/%d", 1) then return true end
-- Safari (non-Chrome, e.g. "Version/17.0 Safari")
if not string.find(ua, "Chrome", 1, true) and string.find(ua, "Safari", 1, true) then return true end
return false
end
local function pass_through()
return true
end
function _M.check()
local ua = ngx.var.http_user_agent or ""
if not is_real_browser(ua) then return pass_through() end
local uri = ngx.var.uri or ""
if uri ~= "/" then return pass_through() end
if ngx.req.get_method and ngx.req.get_method() ~= "GET" then return pass_through() end
local cookie = ngx.var["cookie___openflare_sw"]
if cookie and cookie ~= "" then return pass_through() end
-- intercept: internal redirect to challenge page, which registers SW + sets cookie
local redir = ngx.var.scheme .. "://" .. ngx.var.host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or "")
ngx.req.set_uri_args({ redir = redir })
return ngx.exec("/__openflare_sw_challenge")
end
return _M
`
const openRestySWChallengeLua = `local args = ngx.req.get_uri_args()
local redir = args["redir"] or "/"
-- Escape redir for embedding inside a JS string literal within an HTML
-- <script> element. Backslashes first so later escapes stay escaped, then
-- double quotes (string-literal break-out), then "<" (prevents a raw
-- "</script" sequence ending the element, which the HTML parser matches
-- case-insensitively), then CR/LF (a raw newline would end the literal).
local function escape_redir(value)
local escaped = string.gsub(value, "\\", "\\\\")
escaped = string.gsub(escaped, '"', '\\"')
escaped = string.gsub(escaped, "<", "\\x3C")
escaped = string.gsub(escaped, "\r", "\\r")
escaped = string.gsub(escaped, "\n", "\\n")
return escaped
end
redir = escape_redir(redir)
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>加载中...</title>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then(function () {
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
location.replace("]] .. redir .. [[");
}).catch(function () {
location.replace("]] .. redir .. [[");
});
} else {
document.cookie = "__openflare_sw=1; Path=/; Max-Age=31536000; Secure; SameSite=Lax";
location.replace("]] .. redir .. [[");
}
</script>
</head>
<body>正在加载...</body>
</html>]])
`
// ManagedSWLuaFiles returns embedded Lua assets for the SW offline challenge.
func ManagedSWLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "sw/runtime.lua", Content: openRestySWRuntimeLua},
{Path: "sw/challenge.lua", Content: openRestySWChallengeLua},
}
}
@@ -0,0 +1,32 @@
package nginx
import (
"os"
"path/filepath"
"testing"
lua "github.com/yuin/gopher-lua"
)
func TestSWRuntimeAndChallenge(t *testing.T) {
state := lua.NewState()
defer state.Close()
runtimePath := filepath.Join(t.TempDir(), "runtime.lua")
if err := os.WriteFile(runtimePath, []byte(openRestySWRuntimeLua), 0o644); err != nil {
t.Fatal(err)
}
challengePath := filepath.Join(t.TempDir(), "challenge.lua")
if err := os.WriteFile(challengePath, []byte(openRestySWChallengeLua), 0o644); err != nil {
t.Fatal(err)
}
specPath, err := filepath.Abs("sw_runtime_spec.lua")
if err != nil {
t.Fatal(err)
}
state.SetGlobal("SW_RUNTIME_PATH", lua.LString(runtimePath))
state.SetGlobal("SW_CHALLENGE_PATH", lua.LString(challengePath))
if err := state.DoFile(specPath); err != nil {
t.Fatalf("SW runtime/challenge specification failed: %v", err)
}
}
@@ -0,0 +1,175 @@
local runtime_path = assert(SW_RUNTIME_PATH, "SW_RUNTIME_PATH is required")
local challenge_path = assert(SW_CHALLENGE_PATH, "SW_CHALLENGE_PATH is required")
local function assert_equal(actual, expected, message)
if actual ~= expected then
error((message or "values differ") .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
end
end
-- Stable tables: never rebind `exec_calls` / `redir_args` (closures capture
-- the upvalue slot; rebinding can leave stale values visible under
-- gopher-lua across long test sequences). Clear them in place instead.
local output = {}
local exec_calls = {}
local redir_args = {}
local function clear_state()
for i = 1, #exec_calls do exec_calls[i] = nil end
redir_args.redir = nil
end
ngx = {
var = {},
header = {},
exec = function(uri)
exec_calls[#exec_calls + 1] = uri
return true
end,
say = function(body) output.body = body end,
req = {
get_uri_args = function() return redir_args end,
set_uri_args = function(args) redir_args.redir = args.redir end,
},
}
local function load_runtime()
local chunk = assert(loadfile(runtime_path))
return chunk()
end
local function reset_request(user_agent, uri, cookie, args, method)
clear_state()
ngx.var = {
http_user_agent = user_agent,
uri = uri or "/",
scheme = "https",
host = "example.com",
args = args,
["cookie___openflare_sw"] = cookie,
}
ngx.req.get_method = function() return method or "GET" end
end
local function test_module_contract()
local runtime = load_runtime()
assert_equal(type(runtime), "table", "sw.runtime must return a module table, not true/nil")
assert_equal(type(runtime.check), "function", "sw.runtime must export check()")
end
local function test_non_browser_ua_passes_through()
local runtime = load_runtime()
reset_request("curl/8.0.1")
assert_equal(runtime.check(), true, "non-browser UA passes through")
assert_equal(#exec_calls, 0, "non-browser UA must not intercept")
reset_request("")
assert_equal(runtime.check(), true, "empty UA passes through")
reset_request(nil)
assert_equal(runtime.check(), true, "missing UA passes through")
end
local function test_browser_ua_non_get_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/",
nil,
nil,
"POST"
)
assert_equal(runtime.check(), true, "non-GET request passes through")
assert_equal(#exec_calls, 0, "non-GET request must not be intercepted")
end
local function test_browser_ua_with_cookie_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/",
"1"
)
assert_equal(runtime.check(), true, "browser UA with cookie passes through")
assert_equal(#exec_calls, 0, "cookie holder must not be intercepted")
end
local function test_browser_ua_root_without_cookie_intercepts()
local runtime = load_runtime()
local chrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
reset_request(chrome, "/")
runtime.check()
assert_equal(#exec_calls, 1, "browser without cookie on / must be intercepted once")
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "intercept targets the challenge page")
assert_equal(redir_args.redir, "https://example.com/", "redir arg preserves scheme+host+uri")
reset_request(chrome, "/", nil, "a=1&b=2")
runtime.check()
assert_equal(#exec_calls, 1, "second request also intercepted")
assert_equal(redir_args.redir, "https://example.com/?a=1&b=2", "redir arg keeps the query string")
reset_request("Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0", "/")
runtime.check()
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Firefox intercepted")
reset_request(
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
"/"
)
runtime.check()
assert_equal(exec_calls[1], "/__openflare_sw_challenge", "Safari intercepted")
end
local function test_browser_ua_non_root_passes_through()
local runtime = load_runtime()
reset_request(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"/about"
)
assert_equal(runtime.check(), true, "non-root uri passes through")
assert_equal(#exec_calls, 0, "non-root uri must not be intercepted")
end
local function run_challenge(redir_value)
output.body = nil
ngx.header = {}
redir_args.redir = redir_value
local chunk = assert(loadfile(challenge_path))
chunk()
return output.body
end
local function test_challenge_embeds_plain_redir()
local body = run_challenge("https://example.com/page?a=1&b=2")
assert_equal(
string.find(body, 'location.replace("https://example.com/page?a=1&b=2")', 1, true) ~= nil,
true,
"plain redir embedded verbatim"
)
end
local function test_challenge_escapes_script_breakout()
local payload = '"/><script>alert(1)</script>'
local body = run_challenge(payload)
assert_equal(string.find(body, '"><script>', 1, true), nil, "raw breakout sequence must not appear")
assert_equal(string.find(body, '\\x3C/script>', 1, true) ~= nil, true, "less-than must be hex-escaped")
assert_equal(string.find(body, '\\"', 1, true) ~= nil, true, "double quote must be backslash-escaped")
end
local function test_challenge_escapes_backslash_and_newline()
local payload = 'a\\b";' .. string.char(13, 10)
local body = run_challenge(payload)
assert_equal(string.find(body, 'a\\\\b\\";\\r\\n', 1, true) ~= nil, true, "backslash, quote and CRLF escaped")
end
test_module_contract()
test_non_browser_ua_passes_through()
test_browser_ua_non_get_passes_through()
test_browser_ua_with_cookie_passes_through()
test_browser_ua_root_without_cookie_intercepts()
test_browser_ua_non_root_passes_through()
test_challenge_embeds_plain_redir()
test_challenge_escapes_script_breakout()
test_challenge_escapes_backslash_and_newline()
return true