[功能] POW 集成

This commit is contained in:
ryan
2026-04-19 22:17:58 +08:00
parent f8c1fe804d
commit 9c57ec2f5c
33 changed files with 1980 additions and 34 deletions
+1
View File
@@ -48,3 +48,4 @@ go.work.sum
.DS_Store
.codex-cache
/.gomodcache/
/anubis-1.25.0/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"server_url": "http://127.0.0.1:3000",
"agent_token": "2380de64b00e99093e16590beb91e1a0",
"agent_token": "373956188ddead1df6dd7c86cd330b73",
"data_dir": "./data",
"openresty_container_name": "openflare-openresty",
"openresty_docker_image": "openresty/openresty:alpine",
+110 -9
View File
@@ -27,6 +27,7 @@ const LuaDirPlaceholder = "__OPENFLARE_LUA_DIR__"
const ObservabilityListenPlaceholder = "__OPENFLARE_OBSERVABILITY_LISTEN__"
const ObservabilityPortPlaceholder = "__OPENFLARE_OBSERVABILITY_PORT__"
const ResolverDirectivePlaceholder = "__OPENFLARE_RESOLVER_DIRECTIVE__"
const PowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
const DockerMainConfigPath = "/usr/local/openresty/nginx/conf/nginx.conf"
const DockerRouteConfigPath = "/etc/nginx/conf.d/openflare_routes.conf"
const DockerAccessLogPath = "/etc/nginx/conf.d/openflare_access.log"
@@ -396,6 +397,9 @@ func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, suppor
if err := m.writeCertFiles(supportFiles); err != nil {
return err
}
if err := m.writePowConfig(supportFiles); err != nil {
return err
}
if strings.TrimSpace(m.OpenrestyResolverDirective) == "" && strings.Contains(routeConfig, "set $openflare_upstream ") {
slog.Warn("runtime-resolved hostname upstreams detected without available resolvers; hostname origin requests may fail until resolvers are configured")
}
@@ -450,8 +454,21 @@ func (m *Manager) EnsureLuaAssets() error {
if strings.TrimSpace(m.LuaDir) == "" {
return nil
}
files := make([]managedFile, 0, len(ManagedObservabilityLuaFiles()))
for _, file := range ManagedObservabilityLuaFiles() {
allSupportFiles := append(ManagedObservabilityLuaFiles(), ManagedPowLuaFiles()...)
existingPowConfig, err := m.readPowConfigFile()
if err != nil {
return err
}
if existingPowConfig != nil {
allSupportFiles = append(allSupportFiles, *existingPowConfig)
}
powStaticFiles, err := ManagedPowStaticFiles()
if err != nil {
return fmt.Errorf("load pow static files: %w", err)
}
allSupportFiles = append(allSupportFiles, powStaticFiles...)
files := make([]managedFile, 0, len(allSupportFiles))
for _, file := range allSupportFiles {
targetPath, err := luaFileTargetPath(m.LuaDir, file.Path)
if err != nil {
return err
@@ -536,7 +553,11 @@ func (m *Manager) CurrentChecksum() (string, error) {
if m.NginxCertDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, m.NginxCertDir, CertDirPlaceholder)
}
files, err := m.readCertFiles()
if luaDir := m.luaRuntimePath(); luaDir != "" {
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir+"/pow/static", PowStaticDirPlaceholder)
normalizedRoute = strings.ReplaceAll(normalizedRoute, luaDir, LuaDirPlaceholder)
}
files, err := m.readManagedSupportFiles()
if err != nil {
return "", err
}
@@ -693,6 +714,7 @@ type backupState struct {
RouteExisted bool
RouteData []byte
Files []protocol.SupportFile
PowConfig *protocol.SupportFile
}
type managedFile struct {
@@ -739,6 +761,11 @@ func (m *Manager) backup() (*backupState, error) {
return nil, err
}
state.Files = files
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
state.PowConfig = powConfig
slog.Debug("backup captured", "main_exists", state.MainExisted, "route_exists", state.RouteExisted, "cert_files", len(state.Files))
return state, nil
}
@@ -762,10 +789,12 @@ func (m *Manager) restore(state *backupState) error {
} else if err := os.Remove(m.RouteConfigPath); err != nil && !os.IsNotExist(err) {
return err
}
if m.CertDir == "" {
return nil
if m.CertDir != "" {
if err := m.writeManagedCertFiles(state.Files); err != nil {
return err
}
}
return m.writeManagedCertFiles(state.Files)
return m.restorePowConfig(state)
}
func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
@@ -775,6 +804,26 @@ func (m *Manager) writeCertFiles(certFiles []protocol.SupportFile) error {
return m.writeManagedCertFiles(certFiles)
}
func (m *Manager) writePowConfig(supportFiles []protocol.SupportFile) error {
if m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
for _, file := range supportFiles {
if file.Path == "pow_config.json" {
if err := os.WriteFile(configPath, []byte(file.Content), 0o644); err != nil {
return fmt.Errorf("write pow_config.json: %w", err)
}
slog.Info("wrote pow config", "path", configPath, "size", len(file.Content))
return nil
}
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove pow_config.json: %w", err)
}
return nil
}
func (m *Manager) writeManagedCertFiles(certFiles []protocol.SupportFile) error {
files := make([]managedFile, 0, len(certFiles))
for _, file := range certFiles {
@@ -836,6 +885,53 @@ func (m *Manager) readCertFiles() ([]protocol.SupportFile, error) {
return files, nil
}
func (m *Manager) readPowConfigFile() (*protocol.SupportFile, error) {
if m.LuaDir == "" {
return nil, nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
return &protocol.SupportFile{
Path: "pow_config.json",
Content: string(data),
}, nil
}
func (m *Manager) readManagedSupportFiles() ([]protocol.SupportFile, error) {
files, err := m.readCertFiles()
if err != nil {
return nil, err
}
powConfig, err := m.readPowConfigFile()
if err != nil {
return nil, err
}
if powConfig != nil {
files = append(files, *powConfig)
}
return files, nil
}
func (m *Manager) restorePowConfig(state *backupState) error {
if state == nil || m.LuaDir == "" {
return nil
}
configPath := filepath.Join(m.LuaDir, "pow_config.json")
if state.PowConfig == nil {
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(configPath, []byte(state.PowConfig.Content), 0o644)
}
func (m *Manager) certFileTargetPath(relativePath string) (string, error) {
if strings.TrimSpace(m.CertDir) == "" {
return "", errors.New("cert dir 不能为空")
@@ -988,10 +1084,15 @@ func removeEmptyManagedDirs(baseDir string) error {
}
func (m *Manager) renderRouteConfig(content string) string {
if m.NginxCertDir == "" {
return content
rendered := content
if m.NginxCertDir != "" {
rendered = strings.ReplaceAll(rendered, CertDirPlaceholder, m.NginxCertDir)
}
return strings.ReplaceAll(content, CertDirPlaceholder, m.NginxCertDir)
if luaDir := m.luaRuntimePath(); luaDir != "" {
rendered = strings.ReplaceAll(rendered, LuaDirPlaceholder, luaDir)
rendered = strings.ReplaceAll(rendered, PowStaticDirPlaceholder, luaDir+"/pow/static")
}
return rendered
}
func (m *Manager) renderMainConfig(content string) string {
@@ -736,6 +736,13 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) {
if !strings.Contains(string(routeData), "/etc/nginx/openflare-certs/1.crt") {
t.Fatalf("expected placeholder replacement in route config, got %s", string(routeData))
}
renderedRoute := manager.renderRouteConfig("access_by_lua_file __OPENFLARE_LUA_DIR__/pow/check.lua;\nlocation /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n")
if !strings.Contains(renderedRoute, "access_by_lua_file /etc/nginx/openflare-lua/pow/check.lua;") {
t.Fatalf("expected lua dir placeholder replacement in route config, got %s", renderedRoute)
}
if !strings.Contains(renderedRoute, "alias /etc/nginx/openflare-lua/pow/static/;") {
t.Fatalf("expected pow static dir placeholder replacement in route config, got %s", renderedRoute)
}
mainData, err := os.ReadFile(manager.MainConfigPath)
if err != nil {
t.Fatalf("failed to read main config: %v", err)
@@ -867,6 +874,12 @@ func TestEnsureLuaAssetsKeepsBaseDirAndRemovesStaleFiles(t *testing.T) {
if _, err := os.Stat(filepath.Join(luaDir, "log.lua")); err != nil {
t.Fatalf("expected managed lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "check.lua")); err != nil {
t.Fatalf("expected managed pow lua file to exist, stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(luaDir, "pow", "static", "js", "main.mjs")); err != nil {
t.Fatalf("expected managed pow static asset to exist, stat err = %v", err)
}
}
func TestCertFileMode(t *testing.T) {
@@ -906,6 +919,93 @@ func TestManagerEnsureLuaAssetsWritesReadableFiles(t *testing.T) {
if luaInfo.Mode().Perm() != 0o644 {
t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm())
}
if _, err := os.Stat(filepath.Join(manager.LuaDir, "pow", "check.lua")); err != nil {
t.Fatalf("failed to stat pow lua file: %v", err)
}
}
func TestEnsureLuaAssetsPreservesPowConfig(t *testing.T) {
tempDir := t.TempDir()
luaDir := filepath.Join(tempDir, "lua")
if err := os.MkdirAll(luaDir, 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
powConfigPath := filepath.Join(luaDir, "pow_config.json")
want := `[{"domains":["pow.example.com"],"enabled":true}]`
if err := os.WriteFile(powConfigPath, []byte(want), 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
}
manager := &Manager{LuaDir: luaDir}
if err := manager.EnsureLuaAssets(); err != nil {
t.Fatalf("EnsureLuaAssets failed: %v", err)
}
got, err := os.ReadFile(powConfigPath)
if err != nil {
t.Fatalf("expected pow_config.json to remain after EnsureLuaAssets: %v", err)
}
if string(got) != want {
t.Fatalf("unexpected pow_config.json content: got %s want %s", string(got), want)
}
}
func TestManagerCurrentChecksumIncludesPowConfig(t *testing.T) {
tempDir := t.TempDir()
mainPath := filepath.Join(tempDir, "nginx.conf")
routePath := filepath.Join(tempDir, "routes.conf")
luaDir := filepath.Join(tempDir, "lua")
manager := &Manager{
MainConfigPath: mainPath,
RouteConfigPath: routePath,
LuaDir: luaDir,
NginxLuaDir: "/etc/nginx/openflare-lua",
Executor: &fakeExecutor{},
}
outcome := manager.Apply(
context.Background(),
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if outcome.Status != ApplyStatusSuccess {
t.Fatalf("Apply failed: %#v", outcome)
}
value, err := manager.CurrentChecksum()
if err != nil {
t.Fatalf("CurrentChecksum failed: %v", err)
}
expected := bundleChecksum(
"access_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n",
"location /.within.website/x/cmd/anubis/static/ { alias __OPENFLARE_POW_STATIC_DIR__/; }\n",
[]protocol.SupportFile{{Path: "pow_config.json", Content: `[{"domains":["pow.example.com"],"enabled":true}]`}},
)
if value != expected {
t.Fatalf("unexpected checksum with pow config: got %s want %s", value, expected)
}
}
func TestManagedPowLuaFilesUseInternalChallengeFlow(t *testing.T) {
if !strings.Contains(openRestyPowCheckLua, `return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")`) {
t.Fatal("expected check.lua to internally execute make-challenge instead of issuing a 302 redirect")
}
if strings.Contains(openRestyPowCheckLua, "ngx.redirect(") {
t.Fatal("expected check.lua to avoid external redirects for challenge rendering")
}
if !strings.Contains(openRestyPowChallengeLua, `<h1 id="title" class="centered-div">`) {
t.Fatal("expected challenge html to include Anubis-compatible title node")
}
if !strings.Contains(openRestyPowChallengeLua, `<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>`) {
t.Fatal("expected challenge html to include Anubis-compatible progress markup")
}
if !strings.Contains(openRestyPowChallengeLua, `<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>`) {
t.Fatal("expected challenge html to force Anubis frontend to reuse the current URL as redir target")
}
if !strings.Contains(openRestyPowVerifyLua, `if ngx.var.scheme == "https" then`) {
t.Fatal("expected verify.lua to only mark the session cookie as Secure for HTTPS requests")
}
}
func TestManagerRollbackRestoresCertFiles(t *testing.T) {
@@ -0,0 +1,523 @@
package nginx
import (
"embed"
"openflare-agent/internal/protocol"
"path/filepath"
"strings"
)
//go:embed pow_static
var powStaticFS embed.FS
const openRestyPowCheckLua = `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, "^(.*)/pow/[^/]+%.lua$")
if base_dir and base_dir ~= "" then
package.path = base_dir .. "/?.lua;" .. base_dir .. "/?/init.lua;" .. package.path
end
end
local cjson = require "cjson.safe"
local policy = require "pow.policy"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_sessions = ngx.shared.openflare_pow_sessions
-- Lazy-load pow_config from file; reload when content changes
local function load_pow_config()
local config_paths = {
ngx.config.prefix() .. "openflare-lua/pow_config.json",
"/etc/nginx/openflare-lua/pow_config.json",
"/usr/local/openresty/nginx/conf/pow_config.json"
}
for _, config_path in ipairs(config_paths) do
local f = io.open(config_path, "r")
if f then
local content = f:read("*a")
f:close()
local current_hash = ngx.md5(content or "")
if current_hash == pow_config_dict:get("_config_hash") then
return
end
-- Clear old domain entries
local old_keys = pow_config_dict:get("_domain_keys")
if old_keys then
for domain in string.gmatch(old_keys, "[^\n]+") do
pow_config_dict:delete(domain)
end
end
local domain_keys = {}
if content and content ~= "" and content ~= "{}" then
local ok, entries = pcall(cjson.decode, content)
if ok and entries and type(entries) == "table" then
for _, entry in ipairs(entries) do
if entry.domains then
for _, domain in ipairs(entry.domains) do
pow_config_dict:set(domain, cjson.encode(entry), 0)
domain_keys[#domain_keys+1] = domain
end
end
end
end
end
pow_config_dict:set("_domain_keys", table.concat(domain_keys, "\n"), 0)
pow_config_dict:set("_config_hash", current_hash, 0)
return
end
end
end
load_pow_config()
local host = ngx.var.host
if not host or host == "" then
return
end
local config_raw = pow_config_dict:get(host)
if not config_raw then
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config then
return
end
if not route_config.enabled then
return
end
local config = route_config.config or {}
local uri = ngx.var.uri or ""
local ua = ngx.var.http_user_agent or ""
local remote_ip = ngx.var.remote_addr or ""
-- Check whitelist: if matched, skip PoW
local whitelist = config.whitelist or {}
if policy.match_any(remote_ip, ua, uri, whitelist) then
return
end
-- Check blacklist: if matched, require PoW
local blacklist = config.blacklist or {}
local has_blacklist = policy.has_entries(blacklist)
local need_pow = false
if has_blacklist then
need_pow = policy.match_any(remote_ip, ua, uri, blacklist)
else
-- No blacklist means all non-whitelisted need PoW
need_pow = true
end
if not need_pow then
return
end
-- Check valid session cookie
local cookie_val = ngx.var["cookie___openflare_pow"]
if cookie_val and cookie_val ~= "" then
local session_data = pow_sessions:get(host .. ":" .. cookie_val)
if session_data then
return
end
end
-- If requesting the challenge API endpoints, let them through (handled by content_by_lua)
local anubis_api_prefix = "/.within.website/x/cmd/anubis/api/"
local anubis_static_prefix = "/.within.website/x/cmd/anubis/static/"
if string.sub(uri, 1, #anubis_api_prefix) == anubis_api_prefix then
return
end
if string.sub(uri, 1, #anubis_static_prefix) == anubis_static_prefix then
return
end
-- Render the challenge page through an internal redirect so the browser stays
-- on the originally requested URL instead of seeing a 302 hop.
ngx.req.set_uri_args({
redir = ngx.var.scheme .. "://" .. host .. uri .. (ngx.var.args and ("?" .. ngx.var.args) or ""),
host = host
})
return ngx.exec("/.within.website/x/cmd/anubis/api/make-challenge")
`
const openRestyPowChallengeLua = `local cjson = require "cjson.safe"
local pow_config_dict = ngx.shared.openflare_pow_config
local pow_challenges = ngx.shared.openflare_pow_challenges
local function generate_entropy()
local pieces = {
tostring(ngx.now()),
tostring(ngx.worker.pid()),
tostring(math.random()),
ngx.var.remote_addr or "",
ngx.var.http_user_agent or "",
ngx.var.request_id or "",
}
return table.concat(pieces, ":")
end
local args = ngx.req.get_uri_args()
local host = args["host"] or ngx.var.host or ""
local redir = args["redir"] or ""
local config_raw = pow_config_dict:get(host)
if not config_raw then
ngx.status = 403
ngx.say("PoW not configured for this host")
return
end
local ok, route_config = pcall(cjson.decode, config_raw)
if not ok or not route_config or not route_config.enabled then
ngx.status = 403
ngx.say("PoW not enabled for this host")
return
end
local config = route_config.config or {}
local difficulty = config.difficulty or 4
local algorithm = config.algorithm or "fast"
local challenge_ttl = config.challenge_ttl or 300
local session_ttl = config.session_ttl or 86400
-- Generate challenge data without depending on ngx.random_bytes, which is not
-- available in every OpenResty runtime build.
local entropy = generate_entropy()
local challenge_id = ngx.md5(entropy .. ":id")
local challenge_data = ngx.md5(entropy .. ":data-a") .. ngx.md5(entropy .. ":data-b")
-- Store challenge
local challenge_info = cjson.encode({
data = challenge_data,
difficulty = difficulty,
host = host,
redir = redir,
session_ttl = session_ttl
})
pow_challenges:set(challenge_id, challenge_info, challenge_ttl)
local static_prefix = "/.within.website/x/cmd/anubis/static/"
local title = "Making sure you're not a bot!"
local lang = "en"
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say([[<!DOCTYPE html>
<html lang="]] .. lang .. [[">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>]] .. title .. [[</title>
<link rel="stylesheet" href="]] .. static_prefix .. [[css/xess.css">
<style>
body,html{height:100%;display:flex;justify-content:center;align-items:center;margin-left:auto;margin-right:auto}
.centered-div{text-align:center}
#status{font-variant-numeric:tabular-nums}
#progress{display:none;width:min(20rem,90%);height:2rem;border-radius:1rem;overflow:hidden;margin:1rem 0 2rem;outline-offset:2px;outline:#b16286 solid 4px}
.bar-inner{background-color:#b16286;height:100%;width:0;transition:width .25s ease-in}
</style>
<script id="anubis_version" type="application/json">"openflare-pow"</script>
<script id="anubis_challenge" type="application/json">]] .. cjson.encode({
challenge = {
id = challenge_id,
randomData = challenge_data,
method = algorithm
},
rules = {
difficulty = difficulty,
algorithm = algorithm
}
}) .. [[</script>
<script id="anubis_base_prefix" type="application/json">""</script>
<script id="anubis_public_url" type="application/json">"__openflare_internal__"</script>
</head>
<body id="top">
<main>
<h1 id="title" class="centered-div">]] .. title .. [[</h1>
<div class="centered-div">
<img id="image" style="width:100%;max-width:256px;" src="]] .. static_prefix .. [[img/pensive.webp?cacheBuster=openflare-pow">
<p id="status">Loading...</p>
<p>This site is protected by a Proof-of-Work challenge. Your browser will solve a small puzzle before the upstream response is shown.</p>
<div id="progress" role="progressbar" aria-labelledby="status"><div class="bar-inner"></div></div>
<details>
<summary>Why am I seeing this?</summary>
<p>OpenFlare is asking your browser to complete a lightweight computation to distinguish normal browser traffic from automated abuse. This should finish automatically.</p>
</details>
<noscript><p>JavaScript is required to pass this verification. Please enable JavaScript and reload.</p></noscript>
</div>
</main>
<script type="module" src="]] .. static_prefix .. [[js/main.mjs"></script>
</body>
</html>]])
`
const openRestyPowVerifyLua = `local cjson = require "cjson.safe"
local pow_challenges = ngx.shared.openflare_pow_challenges
local pow_sessions = ngx.shared.openflare_pow_sessions
local args = ngx.req.get_uri_args()
local challenge_id = args["id"] or ""
local response = args["response"] or ""
local nonce_str = args["nonce"] or ""
local redir = args["redir"] or ""
local elapsed = args["elapsedTime"] or ""
if challenge_id == "" or response == "" or nonce_str == "" then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "missing parameters"}))
return
end
local nonce = tonumber(nonce_str)
if not nonce then
ngx.status = 400
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid nonce"}))
return
end
-- Get stored challenge
local challenge_raw = pow_challenges:get(challenge_id)
if not challenge_raw then
ngx.status = 410
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "challenge expired or not found"}))
return
end
local ok, challenge_info = pcall(cjson.decode, challenge_raw)
if not ok or not challenge_info then
ngx.status = 500
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "invalid challenge data"}))
return
end
local challenge_data = challenge_info.data or ""
local difficulty = challenge_info.difficulty or 4
local host = challenge_info.host or ngx.var.host or ""
local session_ttl = challenge_info.session_ttl or 86400
-- Compute SHA-256(challenge_data + nonce)
local calc_string = challenge_data .. tostring(math.floor(nonce))
local calculated = ngx.sha1_bin ~= nil and "" or ""
-- Use resty.sha256 for proper SHA-256
local sha256 = require "resty.sha256"
local str = require "resty.string"
local hasher = sha256:new()
hasher:update(calc_string)
local hash_bytes = hasher:final()
local hash_hex = str.to_hex(hash_bytes)
-- Verify hash matches response
if hash_hex ~= string.lower(response) then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "hash mismatch"}))
return
end
-- Verify difficulty (leading zeros in hex)
local prefix = string.rep("0", difficulty)
if string.sub(hash_hex, 1, difficulty) ~= prefix then
ngx.status = 403
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({error = "insufficient difficulty"}))
return
end
-- Invalidate challenge (prevent replay)
pow_challenges:delete(challenge_id)
-- Generate session token
local session_token = str.to_hex(ngx.sha1_bin(challenge_id .. ngx.now() .. tostring(ngx.worker.pid())))
-- Store session
pow_sessions:set(host .. ":" .. session_token, "1", session_ttl)
-- Set cookie. Secure cookies are not sent over HTTP, so only add Secure when
-- the current request itself is HTTPS.
local cookie = "__openflare_pow=" .. session_token .. "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" .. tostring(session_ttl)
if ngx.var.scheme == "https" then
cookie = cookie .. "; Secure"
end
ngx.header["Set-Cookie"] = cookie
if redir ~= "" then
return ngx.redirect(redir)
end
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({ok = true}))
`
const openRestyPowPolicyLua = `local M = {}
local function match_ip(remote_ip, ips)
if not ips or #ips == 0 then return false end
for _, ip in ipairs(ips) do
if ip == remote_ip then
return true
end
end
return false
end
local function match_cidr(remote_ip, cidrs)
if not cidrs or #cidrs == 0 then return false end
for _, cidr in ipairs(cidrs) do
local m, err = ngx.re.match(cidr, "^(\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3})/(\\\\d{1,2})$")
if m then
local mask_bits = tonumber(m[2])
if mask_bits and mask_bits >= 0 and mask_bits <= 32 then
local function ip_to_num(ip_str)
local parts = {}
for part in string.gmatch(ip_str, "%d+") do
parts[#parts+1] = tonumber(part) or 0
end
if #parts ~= 4 then return 0 end
return parts[1]*16777216 + parts[2]*65536 + parts[3]*256 + parts[4]
end
local remote_num = ip_to_num(remote_ip)
local net_num = ip_to_num(m[1])
if mask_bits == 0 then
return true
end
local mask = math.floor(2^(32 - mask_bits))
mask = 4294967296 - mask
if bit.band(remote_num, mask) == bit.band(net_num, mask) then
return true
end
end
end
end
return false
end
local function match_path(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, "^" .. ngx.re.gsub(pattern, "([%^%$%(%)%%%.%[%]%+%-%?])", function(c)
if c == "*" then return ".*" end
return "%" .. c
end) .. "$", "i")
if ok and match then
return true
end
end
return false
end
local function match_path_regex(uri, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
local ok, match = pcall(ngx.re.match, uri, pattern)
if ok and match then
return true
end
end
return false
end
local function match_ua(ua, patterns)
if not patterns or #patterns == 0 then return false end
for _, pattern in ipairs(patterns) do
if ua and string.find(ua, pattern, 1, true) then
return true
end
end
return false
end
function M.match_any(remote_ip, ua, uri, list)
if not list then return false end
if match_ip(remote_ip, list.ips) then return true end
if match_cidr(remote_ip, list.ip_cidrs) then return true end
if match_path(uri, list.paths) then return true end
if match_path_regex(uri, list.path_regexes) then return true end
if match_ua(ua, list.user_agents) then return true end
return false
end
function M.has_entries(list)
if not list then return false end
return (#(list.ips or {}) + #(list.ip_cidrs or {}) + #(list.paths or {}) + #(list.path_regexes or {}) + #(list.user_agents or {})) > 0
end
return M
`
func ManagedPowLuaFiles() []protocol.SupportFile {
return []protocol.SupportFile{
{Path: "pow/check.lua", Content: openRestyPowCheckLua},
{Path: "pow/challenge.lua", Content: openRestyPowChallengeLua},
{Path: "pow/verify.lua", Content: openRestyPowVerifyLua},
{Path: "pow/policy.lua", Content: openRestyPowPolicyLua},
}
}
func ManagedPowStaticFiles() ([]protocol.SupportFile, error) {
var files []protocol.SupportFile
entries, err := powStaticFS.ReadDir("pow_static")
if err != nil {
return nil, err
}
var walk func(dir string) error
walk = func(dir string) error {
entries, err := powStaticFS.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
fullPath := filepath.Join(dir, entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return err
}
continue
}
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return err
}
// Convert pow_static/css/xess.css -> pow/static/css/xess.css
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
return nil
}
for _, entry := range entries {
fullPath := filepath.Join("pow_static", entry.Name())
if entry.IsDir() {
if err := walk(fullPath); err != nil {
return nil, err
}
} else {
data, err := powStaticFS.ReadFile(fullPath)
if err != nil {
return nil, err
}
relPath := strings.TrimPrefix(fullPath, "pow_static/")
files = append(files, protocol.SupportFile{
Path: "pow/static/" + relPath,
Content: string(data),
})
}
}
return files, nil
}
@@ -0,0 +1,7 @@
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("podkova.woff2") format("woff2");
}
@@ -0,0 +1,149 @@
:root {
--body-sans-font: Geist, sans-serif;
--body-preformatted-font: Iosevka Curly Iaso, monospace;
--body-title-font: Podkova, serif;
--background: #1d2021;
--text: #f9f5d7;
--text-selection: #d3869b;
--preformatted-background: #3c3836;
--link-foreground: #b16286;
--link-background: #282828;
--blockquote-border-left: 1px solid #bdae93;
--progress-bar-outline: #b16286 solid 4px;
--progress-bar-fill: #b16286;
}
@media (prefers-color-scheme: light) {
:root {
--background: #f9f5d7;
--text: #1d2021;
--text-selection: #d3869b;
--preformatted-background: #ebdbb2;
--link-foreground: #b16286;
--link-background: #fbf1c7;
--blockquote-border-left: 1px solid #655c54;
}
}
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./static/geist.woff2") format("woff2");
}
@font-face {
font-family: "Podkova";
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url("./static/podkova.woff2") format("woff2");
}
@font-face {
font-family: "Iosevka Curly";
font-style: monospace;
font-display: swap;
src: url("./static/iosevka-curly.woff2") format("woff2");
}
main {
font-family: var(--body-sans-font);
max-width: 50rem;
padding: 2rem;
margin: auto;
}
::selection {
background: var(--text-selection);
}
body {
background: var(--background);
color: var(--text);
}
body,
html {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-left: auto;
margin-right: auto;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
.centered-div {
text-align: center;
}
#status {
font-variant-numeric: tabular-nums;
}
#progress {
display: none;
width: min(20rem, 90%);
height: 2rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0 2rem;
outline-offset: 2px;
outline: var(--progress-bar-outline);
}
.bar-inner {
background-color: var(--progress-bar-fill);
height: 100%;
width: 0;
transition: width 0.25s ease-in;
}
@media (prefers-reduced-motion: no-preference) {
.bar-inner {
transition: width 0.25s ease-in;
}
}
pre {
background-color: var(--preformatted-background);
padding: 1em;
border: 0;
font-family: var(--body-preformatted-font);
}
a,
a:active,
a:visited {
color: var(--link-foreground);
background-color: var(--link-background);
}
h1,
h2,
h3,
h4,
h5 {
margin-bottom: 0.1rem;
font-family: var(--body-title-font);
}
blockquote {
border-left: var(--blockquote-border-left);
margin: 0.5em 10px;
padding: 0.5em 10px;
}
footer {
text-align: center;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var k=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function n(c,b,w=5,e=null,g,u=Math.trunc(Math.max(k()/2,1))){console.debug("fast algo");let s="purejs";return window.isSecureContext&&(s="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),s="purejs"),new Promise((p,l)=>{let m=`${c.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${s}.mjs?cacheBuster=${c.version}`,f=[],d=!1,a=()=>{console.log("PoW aborted"),i(),l(new DOMException("Aborted","AbortError"))},i=()=>{d||(d=!0,f.forEach(r=>r.terminate()),e?.removeEventListener("abort",a))};if(e!=null){if(e.aborted)return a();e.addEventListener("abort",a,{once:!0})}for(let r=0;r<u;r++){let t=new Worker(m);t.onmessage=o=>{typeof o.data=="number"?g?.(o.data):(i(),p(o.data))},t.onerror=o=>{i(),l(o)},t.postMessage({data:b,difficulty:w,nonce:r,threads:u}),f.push(t)}})}var P={fast:n,slow:n};})();
//# sourceMappingURL=index.mjs.map
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var I=()=>navigator.hardwareConcurrency!==void 0?navigator.hardwareConcurrency:1;function _(e,n,s=5,o=null,i,u=Math.trunc(Math.max(I()/2,1))){console.debug("fast algo");let a="purejs";return window.isSecureContext&&(a="webcrypto"),(navigator.userAgent.includes("Firefox")||navigator.userAgent.includes("Goanna"))&&(console.log("Firefox detected, using pure-JS fallback"),a="purejs"),new Promise((E,x)=>{let M=`${e.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/sha256-${a}.mjs?cacheBuster=${e.version}`,p=[],d=!1,b=()=>{console.log("PoW aborted"),h(),x(new DOMException("Aborted","AbortError"))},h=()=>{d||(d=!0,p.forEach(c=>c.terminate()),o?.removeEventListener("abort",b))};if(o!=null){if(o.aborted)return b();o.addEventListener("abort",b,{once:!0})}for(let c=0;c<u;c++){let g=new Worker(M);g.onmessage=m=>{typeof m.data=="number"?i?.(m.data):(h(),E(m.data))},g.onerror=m=>{h(),x(m)},g.postMessage({data:n,difficulty:s,nonce:c,threads:u}),p.push(g)}})}var j={fast:_,slow:_};var v=(e="",n={})=>{let s=new URL(e,window.location.href);return Object.entries(n).forEach(([o,i])=>s.searchParams.set(o,i)),s.toString()},L=e=>{let n=document.getElementById(e);return n===null?null:JSON.parse(n.textContent)},k=(e,n,s)=>v(`${s}/.within.website/x/cmd/anubis/static/img/${e}.webp`,{cacheBuster:n});var W=async()=>document.documentElement.lang,S=async e=>{let n=L("anubis_base_prefix");if(n!==null)try{return await(await fetch(`${n}/.within.website/x/cmd/anubis/static/locales/${e}.json`)).json()}catch(s){if(console.warn(`Failed to load translations for ${e}, falling back to English`),e!=="en")return await S("en");throw s}},C=()=>{let e=L("anubis_public_url");if(e!==null)return e&&window.location.href.startsWith(e)?new URLSearchParams(window.location.search).get("redir"):window.location.href},$={},D,A=async()=>{D=await W(),$=await S(D)},r=e=>$[`js_${e}`]||$[e]||e;(async()=>{await A();let e=[{name:"Web Workers",msg:r("web_workers_error"),value:window.Worker},{name:"Cookies",msg:r("cookies_error"),value:navigator.cookieEnabled}],n=document.getElementById("status"),s=document.getElementById("image"),o=document.getElementById("title"),i=document.getElementById("progress"),u=L("anubis_version"),a=L("anubis_base_prefix"),E=document.querySelector("details"),x=!1;E&&E.addEventListener("toggle",()=>{E.open&&(x=!0)});let M=({titleMsg:l,statusMsg:f,imageSrc:w})=>{o.innerHTML=l,n.innerHTML=f,s.src=w,i.style.display="none"};n.innerHTML=r("calculating");for(let{value:l,name:f,msg:w}of e)if(!l){M({titleMsg:`${r("missing_feature")} ${f}`,statusMsg:w,imageSrc:k("reject",u,a)});return}let{challenge:p,rules:d}=L("anubis_challenge"),b=j[d.algorithm];if(!b){M({titleMsg:r("challenge_error"),statusMsg:r("challenge_error_msg"),imageSrc:k("reject",u,a)});return}n.innerHTML=`${r("calculating_difficulty")} ${d.difficulty}, `,i.style.display="inline-block";let h=document.createTextNode(`${r("speed")} 0kH/s`);n.appendChild(h);let c=0,g=!1,m=Math.pow(16,-d.difficulty);try{let l=Date.now(),{hash:f,nonce:w}=await b({basePrefix:a,version:u},p.randomData,d.difficulty,null,t=>{let y=Date.now()-l;y-c>1e3&&(c=y,h.data=`${r("speed")} ${(t/y).toFixed(3)}kH/s`);let T=Math.pow(1-m,t),P=(1-Math.pow(T,2))*100;i["aria-valuenow"]=P,i.firstElementChild!==null&&(i.firstElementChild.style.width=`${P}%`),T<.1&&!g&&(n.append(document.createElement("br"),document.createTextNode(r("verification_longer"))),g=!0)}),H=Date.now();if(console.log({hash:f,nonce:w}),x){let y=function(){let T=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:T,elapsedTime:H-l}))},t=document.getElementById("progress");t.style.display="flex",t.style.alignItems="center",t.style.justifyContent="center",t.style.height="2rem",t.style.borderRadius="1rem",t.style.cursor="pointer",t.style.background="#b16286",t.style.color="white",t.style.fontWeight="bold",t.style.outline="4px solid #b16286",t.style.outlineOffset="2px",t.style.width="min(20rem, 90%)",t.style.margin="1rem auto 2rem",t.innerHTML=r("finished_reading"),t.onclick=y,setTimeout(y,3e4)}else{let t=C();window.location.replace(v(`${a}/.within.website/x/cmd/anubis/api/pass-challenge`,{id:p.id,response:f,nonce:w,redir:t,elapsedTime:H-l}))}}catch(l){M({titleMsg:r("calculation_error"),statusMsg:`${r("calculation_error_msg")} ${l.message}`,imageSrc:k("reject",u,a)})}})();})();
//# sourceMappingURL=main.mjs.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
/*
@licstart The following is the entire license notice for the
JavaScript code in this page.
Copyright (c) 2025 Xe Iaso <xe.iaso@techaro.lol>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Includes code from https://github.com/aws/aws-sdk-js-crypto-helpers which is
used under the terms of the Apache 2 license.
@licend The above is the entire license notice
for the JavaScript code in this page.
*/
(()=>{var h=new TextEncoder,y=async e=>{let s=h.encode(e);return await crypto.subtle.digest("SHA-256",s)},g=e=>e.reduce((s,a)=>s+a.toString(16).padStart(2,"0"),"");addEventListener("message",async({data:e})=>{let{data:s,difficulty:a,threads:d}=e,t=e.nonce,f=t===0,o=0,c=Math.floor(a/2),l=a%2!==0;for(;;){let u=await y(s+t),i=new Uint8Array(u),r=!0;for(let n=0;n<c;n++)if(i[n]!==0){r=!1;break}if(r&&l&&i[c]>>4!==0&&(r=!1),r){let n=g(i);postMessage({hash:n,data:s,difficulty:a,nonce:t});return}t+=d,o++,t%1!==0&&(t=Math.trunc(t)),f&&(o&1023)===0&&postMessage(t)}});})();
//# sourceMappingURL=sha256-webcrypto.mjs.map
@@ -0,0 +1,66 @@
{
"loading": "Loading...",
"why_am_i_seeing": "Why am I seeing this?",
"protected_by": "Protected by",
"protected_from": "From",
"made_with": "Made with ❤️ in 🇨🇦",
"mascot_design": "Mascot design by",
"ai_companies_explanation": "You are seeing this because the administrator of this website has set up Anubis to protect the server against the scourge of AI companies aggressively scraping websites. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.",
"anubis_compromise": "Anubis is a compromise. Anubis uses a Proof-of-Work scheme in the vein of Hashcash, a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.",
"hack_purpose": "Ultimately, this is a placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.",
"simplified_explanation": "This is a measure against bots and malicious requests similar to a CAPTCHA. However, instead of having to do work yourself, your browser is given a calculation task that it has to solve to ensure that it is a valid client. This concept is called <a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">Proof of Work</a>. The task is calculated in a few seconds and you are granted access to the website. Thank you for your understanding and patience.",
"jshelter_note": "Please note that Anubis requires the use of modern JavaScript features that plugins like JShelter will disable. Please disable JShelter or other such plugins for this domain.",
"version_info": "This website is running Anubis version",
"try_again": "Try again",
"go_home": "Go home",
"contact_webmaster": "or if you believe you should not be blocked, please contact the webmaster at",
"connection_security": "Please wait a moment while we ensure the security of your connection.",
"javascript_required": "Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed the social contract around how website hosting works. A no-JS solution is a work-in-progress.",
"benchmark_requires_js": "Running the benchmark tool requires JavaScript to be enabled.",
"difficulty": "Difficulty:",
"algorithm": "Algorithm:",
"compare": "Compare:",
"time": "Time",
"iters": "Iters",
"time_a": "Time A",
"iters_a": "Iters A",
"time_b": "Time B",
"iters_b": "Iters B",
"static_check_endpoint": "This is just a check endpoint for your reverse proxy to use.",
"authorization_required": "Authorization required",
"cookies_disabled": "Your browser is configured to disable cookies. Anubis requires cookies for the legitimate interest of making sure you are a valid client. Please enable cookies for this domain",
"access_denied": "Access Denied: error code",
"dronebl_entry": "DroneBL reported an entry",
"see_dronebl_lookup": "see",
"internal_server_error": "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around",
"invalid_redirect": "Invalid redirect",
"redirect_not_parseable": "Redirect URL not parseable",
"redirect_domain_not_allowed": "Redirect domain not allowed",
"missing_required_forwarded_headers": "Missing required X-Forwarded-* headers",
"failed_to_sign_jwt": "failed to sign JWT",
"invalid_invocation": "Invalid invocation of MakeChallenge",
"client_error_browser": "Client Error: Please ensure your browser is up to date and try again later.",
"oh_noes": "Oh noes!",
"benchmarking_anubis": "Benchmarking Anubis!",
"you_are_not_a_bot": "You are not a bot!",
"making_sure_not_bot": "Making sure you're not a bot!",
"celphase": "CELPHASE",
"js_web_crypto_error": "Your browser doesn't have a functioning web.crypto element. Are you viewing this over a secure context?",
"js_web_workers_error": "Your browser doesn't support web workers (Anubis uses this to avoid freezing your browser). Do you have a plugin like JShelter installed?",
"js_cookies_error": "Your browser doesn't store cookies. Anubis uses cookies to determine which clients have passed challenges by storing a signed token in a cookie. Please enable storing cookies for this domain. The names of the cookies Anubis stores may vary without notice. Cookie names and values are not part of the public API.",
"js_context_not_secure": "Your context is not secure!",
"js_context_not_secure_msg": "Try connecting over HTTPS or let the admin know to set up HTTPS. For more information, see <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>.",
"js_calculating": "Calculating...",
"js_missing_feature": "Missing feature",
"js_challenge_error": "Challenge error!",
"js_challenge_error_msg": "Failed to resolve check algorithm. You may want to reload the page.",
"js_calculating_difficulty": "Calculating...<br/>Difficulty:",
"js_speed": "Speed:",
"js_verification_longer": "Verification is taking longer than expected. Please do not refresh the page.",
"js_success": "Success!",
"js_done_took": "Done! Took",
"js_iterations": "iterations",
"js_finished_reading": "I've finished reading, continue →",
"js_calculation_error": "Calculation error!",
"js_calculation_error_msg": "Failed to calculate challenge:"
}
@@ -0,0 +1,66 @@
{
"loading": "加载中...",
"why_am_i_seeing": "为什么我会看到这个?",
"protected_by": "本网站由",
"protected_from": "保护,来自",
"made_with": "在 🇨🇦 用 ❤️ 制作",
"mascot_design": "吉祥物由",
"ai_companies_explanation": "您会看到这个画面,是因为网站管理员启用了 Anubis 来保护服务器,避免 AI 公司大量爬取网站内容。这类行为会导致网站崩溃,让所有用户都无法正常访问资源。",
"anubis_compromise": "Anubis 是一种折中做法。它采用了类似 Hashcash 的工作量证明机制(Proof-of-Work),该机制最初是为了减少垃圾邮件而提出。其核心概念是:对个别用户而言,额外的计算负担可以忽略,但对大规模爬虫来说,累积起来的成本将大幅增加,从而让爬取行为变得更困难。",
"hack_purpose": "最终,这是一个占位符解决方案,以便将更多时间用于指纹识别和识别无头浏览器(例如:通过它们如何进行字体渲染),从而无需向更可能是合法用户的用户呈现挑战工作量证明页面。",
"jshelter_note": "请注意,Anubis 需要使用现代 JavaScript 功能,而像 JShelter 这类插件可能会阻挡这些功能。请为此域名停用 JShelter 或类似的插件。",
"version_info": "这个网站正在运行的 Anubis 版本为",
"try_again": "再试一次",
"go_home": "返回首页",
"contact_webmaster": "或者您觉得您不应该被封锁,请联系网站管理员于",
"connection_security": "请稍等,我们需要在继续之前检查您的连接安全性。",
"javascript_required": "很遗憾,您必须启用 JavaScript 才能通过这项验证。这是因为 AI 公司已经改变了网站托管的社会契约,因此我们必须采取这样的保护机制。无需 JavaScript 的解决方案仍在开发中。",
"benchmark_requires_js": "运行基准测试工具需要启用 JavaScript。",
"difficulty": "难度:",
"algorithm": "算法:",
"compare": "比较:",
"time": "时间",
"iters": "迭代",
"time_a": "时间 A",
"iters_a": "迭代 A",
"time_b": "时间 B",
"iters_b": "迭代 B",
"static_check_endpoint": "这是提供给您的反向代理服务器使用的检查端点。",
"authorization_required": "需要认证",
"cookies_disabled": "您的浏览器目前已禁用 Cookie,为了确认您是合法用户,Anubis 需要启用 Cookie。 请您为此域名启用 Cookie",
"access_denied": "拒绝访问:错误代码",
"dronebl_entry": "DroneBL 报告了一条记录",
"see_dronebl_lookup": "见",
"internal_server_error": "内部服务器错误:管理员错误地配置了 Anubis。 请联系管理员要求他们检查日志",
"invalid_redirect": "无效的重定向",
"redirect_not_parseable": "重定向 URL 无法解析",
"redirect_domain_not_allowed": "重定向的域名并不允许",
"failed_to_sign_jwt": "签署 JWT 失败",
"invalid_invocation": "无效的 MakeChallenge 调用",
"client_error_browser": "客户端错误:请确保您的浏览器是最新版本并稍候再试。",
"oh_noes": "哎呀糟糕了!",
"benchmarking_anubis": "正在进行 Anubis 性能测试!",
"you_are_not_a_bot": "你不是机器人!",
"making_sure_not_bot": "正在确认你是不是机器人!",
"celphase": "CELPHASE 设计",
"js_web_crypto_error": "您的浏览器无法正常使用 web.crypto 组件。您是否通过安全连接(HTTPS)查看此网站?",
"js_web_workers_error": "您的浏览器并不支持 Web workers (Anubis 使用这个来避免冻结您的浏览器 )您有安装像是 JShelter 之类的插件吗?",
"js_cookies_error": "您的浏览器无法存储 Cookie。 Anubis 会使用 Cookie 存储签署的凭证,以判断用户是否已通过验证。请为此域名启用 Cookie 存储功能。 请注意,Anubis 存储的 Cookie 名称可能会变动,且其名称与内容不属于公开 API 的一部分。",
"js_context_not_secure": "您的内容并不安全",
"js_context_not_secure_msg": "请尝试使用 HTTPS 连接,或联系网站管理员设置 HTTPS。更多信息请参见 <a href=\"https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts#when_is_a_context_considered_secure\">MDN</a>。",
"js_calculating": "计算中...",
"js_missing_feature": "缺少功能",
"js_challenge_error": "挑战错误!",
"js_challenge_error_msg": "解决检查算法失败。 您可能会想要刷新页面。",
"js_calculating_difficulty": "计算中...<br/>难度:",
"js_speed": "速度:",
"js_verification_longer": "验证所花的时间高于预期。 请不要刷新页面。",
"js_success": "成功!",
"js_done_took": "完成! 花费",
"js_iterations": "迭代",
"js_finished_reading": "我读完了,继续 →",
"js_calculation_error": "计算错误!",
"js_calculation_error_msg": "计算挑战失败:",
"missing_required_forwarded_headers": "缺少必要的 X-Forwarded-* 头",
"simplified_explanation": "这是一种类似于验证码的措施,用于防止机器人和恶意请求。但是,您无需自己动手,您的浏览器会收到一个计算任务,必须解决该任务以确保它是有效的客户端。这个概念称为<a href=\"https://en.wikipedia.org/wiki/Proof_of_work\">工作量证明</a>。该任务在几秒钟内计算完毕,您将被授予访问网站的权限。感谢您的理解和耐心。"
}
@@ -4,7 +4,7 @@ import "time"
const (
legacyDatabaseSchemaVersion = 1
currentDatabaseSchemaVersion = 8
currentDatabaseSchemaVersion = 9
databaseSchemaVersionRowID = 1
)
+35 -1
View File
@@ -1196,6 +1196,39 @@ func migrateV8(db *gorm.DB, backend string) error {
return backfillProxyRouteDomainCertificateFields(db)
}
// migrateV9 adds PoW (Proof-of-Work) anti-bot protection fields to proxy_routes.
func migrateV9(db *gorm.DB, backend string) error {
if err := applyCurrentSchema(db, backend); err != nil {
return err
}
if err := backfillOriginsFromProxyRoutes(db); err != nil {
return err
}
if err := backfillProxyRouteSiteFields(db); err != nil {
return err
}
if err := ensureProxyRouteSiteNameUniqueIndex(db); err != nil {
return err
}
if err := backfillProxyRouteCertificateFields(db); err != nil {
return err
}
return backfillProxyRouteDomainCertificateFields(db)
}
func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
if err := validateDatabaseSchemaV8(db, backend); err != nil {
return err
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_enabled") {
return fmt.Errorf("column proxy_routes.pow_enabled is missing")
}
if !db.Migrator().HasColumn(&ProxyRoute{}, "pow_config") {
return fmt.Errorf("column proxy_routes.pow_config is missing")
}
return nil
}
func databaseSchemaMigrations() []databaseSchemaMigration {
return []databaseSchemaMigration{
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
@@ -1205,6 +1238,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
{fromVersion: 5, toVersion: 6, migrate: migrateV6, validate: validateDatabaseSchemaV6},
{fromVersion: 6, toVersion: 7, migrate: migrateV7, validate: validateDatabaseSchemaV7},
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
}
}
@@ -1287,7 +1321,7 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
if err := backfillProxyRouteDomainCertificateFields(db); err != nil {
return err
}
if err := validateDatabaseSchemaV8(db, backend); err != nil {
if err := validateDatabaseSchemaV9(db, backend); err != nil {
return err
}
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
+4
View File
@@ -24,6 +24,8 @@ type ProxyRoute struct {
CachePolicy string `json:"cache_policy" gorm:"size:32;not null;default:''"`
CacheRules string `json:"cache_rules" gorm:"type:text;not null;default:'[]'"`
CustomHeaders string `json:"custom_headers" gorm:"type:text;not null;default:'[]'"`
PoWEnabled bool `json:"pow_enabled" gorm:"column:pow_enabled;not null;default:false"`
PoWConfig string `json:"pow_config" gorm:"column:pow_config;type:text;not null;default:'{}'"`
Remark string `json:"remark" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -76,6 +78,8 @@ func (route *ProxyRoute) Update() error {
"cache_policy": route.CachePolicy,
"cache_rules": route.CacheRules,
"custom_headers": route.CustomHeaders,
"pow_enabled": route.PoWEnabled,
"pow_config": route.PoWConfig,
"remark": route.Remark,
}).Error
}
+4 -2
View File
@@ -217,7 +217,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
return nil, err
}
}
supportFiles = filterCertificateSupportFiles(supportFiles)
supportFiles = filterAgentSupportFiles(supportFiles)
slog.Debug("agent fetched active config", "version", version.Version, "checksum", version.Checksum)
return &AgentConfigResponse{
Version: version.Version,
@@ -230,7 +230,7 @@ func GetActiveConfigForAgent() (*AgentConfigResponse, error) {
}, nil
}
func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
func filterAgentSupportFiles(files []SupportFile) []SupportFile {
if len(files) == 0 {
return nil
}
@@ -240,6 +240,8 @@ func filterCertificateSupportFiles(files []SupportFile) []SupportFile {
switch {
case strings.HasSuffix(path, ".crt"), strings.HasSuffix(path, ".key"), strings.HasSuffix(path, ".pem"):
filtered = append(filtered, file)
case path == "pow_config.json":
filtered = append(filtered, file)
}
}
return filtered
+41
View File
@@ -0,0 +1,41 @@
package service
import "testing"
func TestGetActiveConfigForAgentIncludesPoWConfig(t *testing.T) {
setupServiceTestDB(t)
_, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow-agent.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":4,"algorithm":"fast","session_ttl":86400,"challenge_ttl":300,"whitelist":{"paths":["/.well-known/*","/favicon.ico","/robots.txt"],"user_agents":["Googlebot","bingbot","Baiduspider"]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]}}`,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
if _, err := PublishConfigVersion("root"); err != nil {
t.Fatalf("PublishConfigVersion failed: %v", err)
}
activeConfig, err := GetActiveConfigForAgent()
if err != nil {
t.Fatalf("GetActiveConfigForAgent failed: %v", err)
}
foundPowConfig := false
for _, file := range activeConfig.SupportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowConfig = true
if file.Content == "" {
t.Fatal("expected pow_config.json content to be populated")
}
}
if !foundPowConfig {
t.Fatal("expected agent config to include pow_config.json support file")
}
}
+135 -9
View File
@@ -83,6 +83,8 @@ type snapshotRoute struct {
CachePolicy string `json:"cache_policy,omitempty"`
CacheRules []string `json:"cache_rules,omitempty"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers,omitempty"`
PoWEnabled bool `json:"pow_enabled,omitempty"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config,omitempty"`
Remark string `json:"remark,omitempty"`
}
@@ -429,7 +431,13 @@ func buildCurrentConfigBundle(requireRoutes bool) (*configBundle, error) {
if err != nil {
return nil, err
}
powConfigJSON, powSupportFiles, err := renderPowConfigBundle(routes)
if err != nil {
return nil, err
}
supportFiles = append(supportFiles, powSupportFiles...)
mainConfig := renderMainConfig(openRestyConfig)
supportFiles = append(supportFiles, SupportFile{Path: "pow_config.json", Content: powConfigJSON})
return &configBundle{
Routes: routes,
SnapshotRoutes: snapshotRoutes,
@@ -462,6 +470,13 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
if err != nil {
return nil, fmt.Errorf("路由 %s 缓存规则无效", route.Domain)
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, fmt.Errorf("路由 %s PoW 配置无效", route.Domain)
}
if !route.PoWEnabled {
powConfig = nil
}
items = append(items, snapshotRoute{
SiteName: normalizeProxyRouteSiteNameInput(route, route.SiteName, domains[0]),
Domain: domains[0],
@@ -482,6 +497,8 @@ func buildSnapshotRoutes(routes []*model.ProxyRoute) ([]snapshotRoute, error) {
CachePolicy: route.CachePolicy,
CacheRules: cacheRules,
CustomHeaders: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
Remark: route.Remark,
})
}
@@ -586,6 +603,17 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
if err == nil {
routes[index].LimitRate = normalizedLimitRate
}
if routes[index].PoWEnabled {
raw, err := json.Marshal(routes[index].PoWConfig)
if err == nil {
normalizedPoWConfig, err := normalizePoWConfig(true, string(raw))
if err == nil {
routes[index].PoWConfig = &normalizedPoWConfig
}
}
} else {
routes[index].PoWConfig = nil
}
}
return routes
}
@@ -611,7 +639,7 @@ func flattenSnapshotRoutesByDomain(routes []snapshotRoute) map[string]snapshotRo
}
func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
if left.SiteName != right.SiteName || left.Domain != right.Domain || left.OriginURL != right.OriginURL || left.OriginHost != right.OriginHost || left.EnableHTTPS != right.EnableHTTPS || left.RedirectHTTP != right.RedirectHTTP || left.LimitConnPerServer != right.LimitConnPerServer || left.LimitConnPerIP != right.LimitConnPerIP || left.LimitRate != right.LimitRate || left.CacheEnabled != right.CacheEnabled || left.CachePolicy != right.CachePolicy || left.PoWEnabled != right.PoWEnabled || !uintSliceEqual(left.CertIDs, right.CertIDs) || !uintSliceEqual(left.DomainCertIDs, right.DomainCertIDs) {
return false
}
if len(left.Domains) != len(right.Domains) {
@@ -646,6 +674,41 @@ func snapshotRouteConfigEqual(left snapshotRoute, right snapshotRoute) bool {
return false
}
}
if !snapshotPoWConfigEqual(left.PoWConfig, right.PoWConfig) {
return false
}
return true
}
func snapshotPoWConfigEqual(left *ProxyRoutePoWConfig, right *ProxyRoutePoWConfig) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return left.Difficulty == right.Difficulty &&
left.Algorithm == right.Algorithm &&
left.SessionTTL == right.SessionTTL &&
left.ChallengeTTL == right.ChallengeTTL &&
stringSliceEqual(left.Whitelist.IPs, right.Whitelist.IPs) &&
stringSliceEqual(left.Whitelist.IPCidrs, right.Whitelist.IPCidrs) &&
stringSliceEqual(left.Whitelist.Paths, right.Whitelist.Paths) &&
stringSliceEqual(left.Whitelist.PathRegexes, right.Whitelist.PathRegexes) &&
stringSliceEqual(left.Whitelist.UserAgents, right.Whitelist.UserAgents) &&
stringSliceEqual(left.Blacklist.IPs, right.Blacklist.IPs) &&
stringSliceEqual(left.Blacklist.IPCidrs, right.Blacklist.IPCidrs) &&
stringSliceEqual(left.Blacklist.Paths, right.Blacklist.Paths) &&
stringSliceEqual(left.Blacklist.PathRegexes, right.Blacklist.PathRegexes) &&
stringSliceEqual(left.Blacklist.UserAgents, right.Blacklist.UserAgents)
}
func stringSliceEqual(left []string, right []string) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
@@ -835,7 +898,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
builder.WriteString(renderNamedUpstreamBlock(upstreamConfig))
}
if !route.EnableHTTPS {
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
continue
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
@@ -896,7 +959,7 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
if route.RedirectHTTP {
if len(httpOnlyDomains) > 0 {
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(renderServerNames(httpOnlyDomains), route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
@@ -906,14 +969,14 @@ func renderRouteConfig(routes []*model.ProxyRoute, cfg openRestyConfigSnapshot)
builder.WriteString(renderHTTPRedirectServer(renderServerNames(assignedDomains)))
}
} else {
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPProxyServer(serverNames, route.OriginURL, route.OriginHost, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
for _, certID := range certIDs {
assignedDomains := domainsByCertID[certID]
if len(assignedDomains) == 0 {
continue
}
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, cfg))
builder.WriteString(renderHTTPSServer(renderServerNames(assignedDomains), route.OriginURL, route.OriginHost, certID, customHeaders, cacheConfig, limitConfig, upstreamConfig, route.PoWEnabled, cfg))
}
}
return builder.String(), dedupeSupportFiles(supportFiles), nil
@@ -1020,6 +1083,32 @@ func onOff(value bool) string {
return "off"
}
const nginxPowStaticDirPlaceholder = "__OPENFLARE_POW_STATIC_DIR__"
func renderPowAccessBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" access_by_lua_file %s/pow/check.lua;\n", nginxLuaDirPlaceholder)
}
func renderPowLocationBlocks(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf("\n location = %spass-challenge {\n content_by_lua_file %s/pow/verify.lua;\n }\n\n location = %smake-challenge {\n content_by_lua_file %s/pow/challenge.lua;\n }\n\n", anubisAPIPrefix, nginxLuaDirPlaceholder, anubisAPIPrefix, nginxLuaDirPlaceholder)
}
func renderPowStaticLocationBlock(powEnabled bool) string {
if !powEnabled {
return ""
}
return fmt.Sprintf(" location %s {\n alias %s/;\n }\n\n", anubisStaticPrefix, nginxPowStaticDirPlaceholder)
}
const anubisStaticPrefix = "/.within.website/x/cmd/anubis/static/"
const anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
func normalizeSnapshotCertificateIDs(primaryCertID *uint, certIDs []uint) ([]uint, *uint, error) {
candidates := make([]uint, 0, len(certIDs)+1)
if primaryCertID != nil && *primaryCertID != 0 {
@@ -1130,18 +1219,18 @@ func nextVersionNumber(now time.Time) (string, error) {
return fmt.Sprintf("%s-%03d", prefix, count+1), nil
}
func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n\n location / {\n%s%s%s%s }\n}\n\n", serverNames, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
func renderHTTPProxyServer(serverNames string, originURL string, originHost string, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
return fmt.Sprintf("server {\n listen 80;\n server_name %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
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, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
func renderHTTPSServer(serverNames string, originURL string, originHost string, certificateID uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, powEnabled bool, cfg openRestyConfigSnapshot) string {
certPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateCertFileName(certificateID))
keyPath := fmt.Sprintf("%s/%s", nginxCertDirPlaceholder, certificateKeyFileName(certificateID))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n\n location / {\n%s%s%s%s }\n}\n\n", serverNames, certPath, keyPath, renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig))
return fmt.Sprintf("server {\n listen 443 ssl;\n http2 on;\n server_name %s;\n ssl_certificate %s;\n ssl_certificate_key %s;\n%s location / {\n%s%s%s%s%s }\n%s}\n\n", serverNames, certPath, keyPath, renderPowLocationBlocks(powEnabled), renderPowAccessBlock(powEnabled), renderProxyHeaderBlock(originURL, originHost, customHeaders, upstreamConfig), renderRouteLimitBlock(limitConfig), renderRouteCacheBlock(cacheConfig, cfg), renderProxyPassBlock(originURL, upstreamConfig), renderPowStaticLocationBlock(powEnabled))
}
func renderHTTPSServerWithCertificates(serverNames string, originURL string, originHost string, certificateIDs []uint, customHeaders []ProxyRouteCustomHeaderInput, cacheConfig routeCacheConfig, limitConfig routeLimitConfig, upstreamConfig routeUpstreamConfig, cfg openRestyConfigSnapshot) string {
@@ -1505,3 +1594,40 @@ func dedupeSupportFiles(files []SupportFile) []SupportFile {
}
return result
}
func renderPowConfigBundle(routes []*model.ProxyRoute) (string, []SupportFile, error) {
type domainEntry struct {
Domains []string `json:"domains"`
Enabled bool `json:"enabled"`
Config map[string]interface{} `json:"config"`
}
entries := make([]domainEntry, 0)
hasPow := false
for _, route := range routes {
if !route.PoWEnabled {
continue
}
hasPow = true
domains, err := decodeStoredDomains(route.Domains, route.Domain)
if err != nil {
return "", nil, err
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(route.PoWConfig), &cfg); err != nil {
return "", nil, fmt.Errorf("route %s pow_config is invalid", route.Domain)
}
entries = append(entries, domainEntry{
Domains: domains,
Enabled: true,
Config: cfg,
})
}
if !hasPow {
return "{}", nil, nil
}
data, err := json.Marshal(entries)
if err != nil {
return "", nil, err
}
return string(data), nil, nil
}
@@ -5,6 +5,7 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"math/big"
"openflare/common"
@@ -923,6 +924,90 @@ func TestPreviewAndDiffConfigVersion(t *testing.T) {
}
}
func TestPublishConfigVersionDetectsPoWChanges(t *testing.T) {
setupServiceTestDB(t)
route, err := CreateProxyRoute(ProxyRouteInput{
Domain: "pow.example.com",
OriginURL: "https://origin.internal",
Enabled: true,
})
if err != nil {
t.Fatalf("CreateProxyRoute failed: %v", err)
}
firstRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("initial PublishConfigVersion failed: %v", err)
}
if !strings.Contains(firstRelease.Version.SupportFilesJSON, `"path":"pow_config.json"`) {
t.Fatal("expected publish to include pow_config.json support file")
}
_, err = UpdateProxyRoute(route.ID, ProxyRouteInput{
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: true,
PoWEnabled: true,
PoWConfig: `{"difficulty":5,"algorithm":"slow","session_ttl":7200,"challenge_ttl":180,"whitelist":{"ips":["127.0.0.1"],"ip_cidrs":[],"paths":[],"path_regexes":[],"user_agents":[]},"blacklist":{"ips":[],"ip_cidrs":[],"paths":["/login"],"path_regexes":[],"user_agents":[]}}`,
RedirectHTTP: false,
})
if err != nil {
t.Fatalf("UpdateProxyRoute failed: %v", err)
}
diff, err := DiffConfigVersion()
if err != nil {
t.Fatalf("DiffConfigVersion failed: %v", err)
}
if len(diff.ModifiedDomains) != 1 || diff.ModifiedDomains[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark domain as modified, got %#v", diff.ModifiedDomains)
}
if len(diff.ModifiedSites) != 1 || diff.ModifiedSites[0] != "pow.example.com" {
t.Fatalf("expected PoW change to mark site as modified, got %#v", diff.ModifiedSites)
}
secondRelease, err := PublishConfigVersion("root")
if err != nil {
t.Fatalf("PublishConfigVersion after PoW change failed: %v", err)
}
if firstRelease.Version.Checksum == secondRelease.Version.Checksum {
t.Fatal("expected PoW change to alter published checksum")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"pow_enabled":true`) {
t.Fatal("expected snapshot to persist PoW enabled state")
}
if !strings.Contains(secondRelease.Version.MainConfig, "lua_shared_dict openflare_pow_config 1m;") {
t.Fatal("expected main config to declare shared dict for pow config")
}
if !strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/ {") {
t.Fatal("expected rendered config to expose anubis static location")
}
if strings.Contains(secondRelease.Version.RenderedConfig, "location /.within.website/x/cmd/anubis/static/static/ {") {
t.Fatal("expected rendered config to avoid duplicate static path segment")
}
if !strings.Contains(secondRelease.Version.SnapshotJSON, `"difficulty":5`) {
t.Fatal("expected snapshot to persist PoW config")
}
var supportFiles []SupportFile
if err := json.Unmarshal([]byte(secondRelease.Version.SupportFilesJSON), &supportFiles); err != nil {
t.Fatalf("failed to decode support files: %v", err)
}
foundPowSupportFile := false
for _, file := range supportFiles {
if file.Path != "pow_config.json" {
continue
}
foundPowSupportFile = true
if !strings.Contains(file.Content, `"difficulty":5`) {
t.Fatalf("expected pow support file to persist config, got %s", file.Content)
}
}
if !foundPowSupportFile {
t.Fatal("expected publish to include pow_config.json support file")
}
}
func TestRenderConfigUsesDefaultServerFallback(t *testing.T) {
setupServiceTestDB(t)
@@ -11,6 +11,9 @@ const (
func renderOpenRestyObservabilityTemplateBlock() string {
return stringsJoinLines(
" lua_shared_dict openflare_observability 10m;",
" lua_shared_dict openflare_pow_config 1m;",
" lua_shared_dict openflare_pow_challenges 10m;",
" lua_shared_dict openflare_pow_sessions 20m;",
fmt.Sprintf(" init_worker_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityInitLuaPath),
fmt.Sprintf(" log_by_lua_file %s/%s;", nginxLuaDirPlaceholder, openRestyObservabilityLogLuaPath),
"",
+152
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"openflare/model"
"regexp"
@@ -53,6 +54,8 @@ type ProxyRouteInput struct {
CachePolicy string `json:"cache_policy"`
CacheRules []string `json:"cache_rules"`
CustomHeaders []ProxyRouteCustomHeaderInput `json:"custom_headers"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig string `json:"pow_config"`
Remark string `json:"remark"`
}
@@ -83,6 +86,8 @@ type ProxyRouteView struct {
CacheRuleList []string `json:"cache_rule_list"`
CustomHeaders string `json:"custom_headers"`
CustomHeaderList []ProxyRouteCustomHeaderInput `json:"custom_header_list"`
PoWEnabled bool `json:"pow_enabled"`
PoWConfig *ProxyRoutePoWConfig `json:"pow_config"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -196,6 +201,16 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
if err != nil {
return nil, err
}
powConfig, err := normalizePoWConfig(input.PoWEnabled, input.PoWConfig)
if err != nil {
return nil, err
}
powConfigJSON, err := json.Marshal(powConfig)
if err != nil {
return nil, err
}
if !input.EnableHTTPS {
input.RedirectHTTP = false
input.CertID = nil
@@ -267,6 +282,8 @@ func buildProxyRoute(route *model.ProxyRoute, input ProxyRouteInput) (*model.Pro
route.CachePolicy = normalizeCachePolicy(input.CacheEnabled, cachePolicy)
route.CacheRules = string(cacheRulesJSON)
route.CustomHeaders = string(customHeadersJSON)
route.PoWEnabled = input.PoWEnabled
route.PoWConfig = string(powConfigJSON)
route.Remark = remark
return route, nil
}
@@ -303,6 +320,10 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
if err != nil {
return nil, err
}
powConfig, err := decodeStoredPoWConfig(route.PoWEnabled, route.PoWConfig)
if err != nil {
return nil, err
}
certIDs, err := decodeStoredCertIDs(route.CertIDs, route.CertID)
if err != nil {
return nil, err
@@ -343,6 +364,8 @@ func buildProxyRouteView(route *model.ProxyRoute) (*ProxyRouteView, error) {
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
CustomHeaderList: customHeaders,
PoWEnabled: route.PoWEnabled,
PoWConfig: powConfig,
Remark: route.Remark,
CreatedAt: route.CreatedAt,
UpdatedAt: route.UpdatedAt,
@@ -1048,3 +1071,132 @@ func validateOriginHost(raw string) error {
func isUniqueConstraintError(err error) bool {
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique")
}
// PoW configuration types and validation
type ProxyRoutePoWListConfig struct {
IPs []string `json:"ips"`
IPCidrs []string `json:"ip_cidrs"`
Paths []string `json:"paths"`
PathRegexes []string `json:"path_regexes"`
UserAgents []string `json:"user_agents"`
}
type ProxyRoutePoWConfig struct {
Difficulty int `json:"difficulty"`
Algorithm string `json:"algorithm"`
SessionTTL int `json:"session_ttl"`
ChallengeTTL int `json:"challenge_ttl"`
Whitelist ProxyRoutePoWListConfig `json:"whitelist"`
Blacklist ProxyRoutePoWListConfig `json:"blacklist"`
}
var powAlgorithmValues = map[string]bool{"fast": true, "slow": true}
func defaultPoWConfig() ProxyRoutePoWConfig {
return ProxyRoutePoWConfig{
Difficulty: 4,
Algorithm: "fast",
SessionTTL: 86400,
ChallengeTTL: 300,
Whitelist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
Blacklist: ProxyRoutePoWListConfig{IPs: []string{}, IPCidrs: []string{}, Paths: []string{}, PathRegexes: []string{}, UserAgents: []string{}},
}
}
func normalizePoWConfig(enabled bool, raw string) (ProxyRoutePoWConfig, error) {
if !enabled {
return defaultPoWConfig(), nil
}
cfg := defaultPoWConfig()
text := strings.TrimSpace(raw)
if text != "" && text != "{}" {
if err := json.Unmarshal([]byte(text), &cfg); err != nil {
return cfg, errors.New("pow_config 格式无效")
}
}
if cfg.Difficulty < 1 || cfg.Difficulty > 16 {
return cfg, errors.New("pow_config.difficulty 必须在 1-16 之间")
}
if !powAlgorithmValues[cfg.Algorithm] {
return cfg, errors.New("pow_config.algorithm 必须为 fast 或 slow")
}
if cfg.SessionTTL < 60 {
return cfg, errors.New("pow_config.session_ttl 不能小于 60 秒")
}
if cfg.ChallengeTTL < 30 {
return cfg, errors.New("pow_config.challenge_ttl 不能小于 30 秒")
}
for _, cidr := range cfg.Whitelist.IPCidrs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return cfg, fmt.Errorf("pow_config 白名单 IP CIDR 格式无效: %s", cidr)
}
}
for _, cidr := range cfg.Blacklist.IPCidrs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
return cfg, fmt.Errorf("pow_config 黑名单 IP CIDR 格式无效: %s", cidr)
}
}
for _, re := range cfg.Whitelist.PathRegexes {
if _, err := regexp.Compile(re); err != nil {
return cfg, fmt.Errorf("pow_config 白名单路径正则格式无效: %s", re)
}
}
for _, re := range cfg.Blacklist.PathRegexes {
if _, err := regexp.Compile(re); err != nil {
return cfg, fmt.Errorf("pow_config 黑名单路径正则格式无效: %s", re)
}
}
for _, ip := range cfg.Whitelist.IPs {
if net.ParseIP(ip) == nil {
return cfg, fmt.Errorf("pow_config 白名单 IP 格式无效: %s", ip)
}
}
for _, ip := range cfg.Blacklist.IPs {
if net.ParseIP(ip) == nil {
return cfg, fmt.Errorf("pow_config 黑名单 IP 格式无效: %s", ip)
}
}
type dimension struct {
name string
wl []string
bl []string
}
dimensions := []dimension{
{"IP", cfg.Whitelist.IPs, cfg.Blacklist.IPs},
{"IP CIDR", cfg.Whitelist.IPCidrs, cfg.Blacklist.IPCidrs},
{"路径", cfg.Whitelist.Paths, cfg.Blacklist.Paths},
{"路径正则", cfg.Whitelist.PathRegexes, cfg.Blacklist.PathRegexes},
{"User-Agent", cfg.Whitelist.UserAgents, cfg.Blacklist.UserAgents},
}
for _, dim := range dimensions {
if len(dim.wl) > 0 && len(dim.bl) > 0 {
return cfg, fmt.Errorf("pow_config %s 不能同时配置白名单和黑名单", dim.name)
}
}
return cfg, nil
}
func decodeStoredPoWConfig(enabled bool, raw string) (*ProxyRoutePoWConfig, error) {
if !enabled {
cfg := defaultPoWConfig()
return &cfg, nil
}
text := strings.TrimSpace(raw)
if text == "" || text == "{}" {
cfg := defaultPoWConfig()
return &cfg, nil
}
var cfg ProxyRoutePoWConfig
if err := json.Unmarshal([]byte(text), &cfg); err != nil {
return nil, errors.New("pow_config 格式无效")
}
return &cfg, nil
}
+11 -11
View File
@@ -5,18 +5,18 @@ import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers';
import { getThemeInitScript } from '@/lib/theme/theme';
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
import './globals.css';
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
export const metadata: Metadata = {
title: {
default: 'OpenFlare 控制台',
template: '%s | OpenFlare',
},
description: 'OpenFlare 管理端新版工程骨架',
description: 'OpenFlare 管理端',
applicationName: 'OpenFlare',
};
@@ -24,8 +24,8 @@ interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning className={cn("font-sans", geist.variable)}>
<body>
<Script id='theme-init' strategy='beforeInteractive'>
@@ -34,5 +34,5 @@ export default function RootLayout({ children }: RootLayoutProps) {
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
);
}
@@ -710,6 +710,324 @@ function CacheSection({
);
}
type PowListValues = {
ips: string;
ip_cidrs: string;
paths: string;
path_regexes: string;
user_agents: string;
};
const defaultPowList: PowListValues = {
ips: '',
ip_cidrs: '',
paths: '',
path_regexes: '',
user_agents: '',
};
const powSchema = z
.object({
pow_enabled: z.boolean(),
difficulty: z.coerce.number().int().min(1).max(16),
algorithm: z.enum(['fast', 'slow']),
session_ttl: z.coerce.number().int().min(60),
challenge_ttl: z.coerce.number().int().min(30),
whitelist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
blacklist: z.object({
ips: z.string(),
ip_cidrs: z.string(),
paths: z.string(),
path_regexes: z.string(),
user_agents: z.string(),
}),
})
.superRefine((value, context) => {
if (!value.pow_enabled) return;
const dimensions: { key: string; label: string }[] = [
{ key: 'ips', label: 'IP' },
{ key: 'ip_cidrs', label: 'IP CIDR' },
{ key: 'paths', label: '路径' },
{ key: 'path_regexes', label: '路径正则' },
{ key: 'user_agents', label: 'User-Agent' },
];
for (const dim of dimensions) {
const wl = linesFromTextarea(
(value.whitelist as Record<string, string>)[dim.key] || '',
);
const bl = linesFromTextarea(
(value.blacklist as Record<string, string>)[dim.key] || '',
);
if (wl.length > 0 && bl.length > 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: `${dim.label} 不能同时配置白名单和黑名单`,
path: ['blacklist', dim.key],
});
}
}
});
type PowValues = z.infer<typeof powSchema>;
function buildPowListFromConfig(
list:
| { ips?: string[]; ip_cidrs?: string[]; paths?: string[]; path_regexes?: string[]; user_agents?: string[] }
| undefined,
): PowListValues {
return {
ips: (list?.ips ?? []).join('\n'),
ip_cidrs: (list?.ip_cidrs ?? []).join('\n'),
paths: (list?.paths ?? []).join('\n'),
path_regexes: (list?.path_regexes ?? []).join('\n'),
user_agents: (list?.user_agents ?? []).join('\n'),
};
}
function PowSection({
route,
saving,
onSave,
}: {
route: ProxyRouteItem;
saving: boolean;
onSave: SaveHandler;
}) {
const powConfig = route.pow_config;
const form = useForm<PowValues>({
resolver: zodResolver(powSchema),
defaultValues: {
pow_enabled: route.pow_enabled,
difficulty: powConfig?.difficulty ?? 4,
algorithm: powConfig?.algorithm ?? 'fast',
session_ttl: powConfig?.session_ttl ?? 86400,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
},
});
useEffect(() => {
form.reset({
pow_enabled: route.pow_enabled,
difficulty: powConfig?.difficulty ?? 4,
algorithm: powConfig?.algorithm ?? 'fast',
session_ttl: powConfig?.session_ttl ?? 86400,
challenge_ttl: powConfig?.challenge_ttl ?? 300,
whitelist: buildPowListFromConfig(powConfig?.whitelist),
blacklist: buildPowListFromConfig(powConfig?.blacklist),
});
}, [form, route, powConfig]);
const watchedEnabled = form.watch('pow_enabled');
const parseList = (text: string): string[] =>
linesFromTextarea(text).filter(Boolean);
return (
<ConfigSectionShell
title="PoW 防护"
description="启用 Proof-of-Work 反爬虫验证。首次访问的浏览器需要完成计算挑战才能继续。"
formId="proxy-route-pow-form"
saving={saving}
>
<form
id="proxy-route-pow-form"
className="space-y-5"
onSubmit={form.handleSubmit((values) => {
const powConfigPayload = JSON.stringify({
difficulty: values.difficulty,
algorithm: values.algorithm,
session_ttl: values.session_ttl,
challenge_ttl: values.challenge_ttl,
whitelist: {
ips: parseList(values.whitelist.ips),
ip_cidrs: parseList(values.whitelist.ip_cidrs),
paths: parseList(values.whitelist.paths),
path_regexes: parseList(values.whitelist.path_regexes),
user_agents: parseList(values.whitelist.user_agents),
},
blacklist: {
ips: parseList(values.blacklist.ips),
ip_cidrs: parseList(values.blacklist.ip_cidrs),
paths: parseList(values.blacklist.paths),
path_regexes: parseList(values.blacklist.path_regexes),
user_agents: parseList(values.blacklist.user_agents),
},
});
onSave(
buildPayloadFromRoute(route, {
pow_enabled: values.pow_enabled,
pow_config: powConfigPayload,
}),
{ message: 'PoW 防护设置已保存。' },
);
})}
>
<ToggleField
label="启用 PoW 防护"
description="对访问此站点的请求进行 Proof-of-Work 验证,阻止自动化爬虫。"
checked={watchedEnabled}
onChange={(checked) =>
form.setValue('pow_enabled', checked, { shouldDirty: true })
}
/>
<ResourceField label="验证算法">
<ResourceSelect
disabled={!watchedEnabled}
{...form.register('algorithm')}
>
<option value="fast">Fast(WebCrypto SHA-256)</option>
<option value="slow">Slow(兼容模式)</option>
</ResourceSelect>
</ResourceField>
<ResourceField
label="难度"
hint="数值越高验证越慢,1-16。推荐 3-5。"
error={form.formState.errors.difficulty?.message}
>
<ResourceInput
type="number"
min={1}
max={16}
disabled={!watchedEnabled}
{...form.register('difficulty')}
/>
</ResourceField>
<ResourceField
label="会话有效期(秒)"
hint="通过验证后 Cookie 的有效期。"
error={form.formState.errors.session_ttl?.message}
>
<ResourceInput
type="number"
min={60}
disabled={!watchedEnabled}
{...form.register('session_ttl')}
/>
</ResourceField>
<ResourceField
label="挑战有效期(秒)"
hint="挑战令牌的有效期。"
error={form.formState.errors.challenge_ttl?.message}
>
<ResourceInput
type="number"
min={30}
disabled={!watchedEnabled}
{...form.register('challenge_ttl')}
/>
</ResourceField>
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
白名单(匹配的请求跳过 PoW)
</legend>
<ResourceField label="IP" hint="每行一个 IP 地址">
<ResourceTextarea
className="min-h-20"
placeholder="1.2.3.4&#10;5.6.7.8"
{...form.register('whitelist.ips')}
/>
</ResourceField>
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
<ResourceTextarea
className="min-h-20"
placeholder="10.0.0.0/8&#10;192.168.0.0/16"
{...form.register('whitelist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/.well-known/*&#10;/favicon.ico"
{...form.register('whitelist.paths')}
/>
</ResourceField>
<ResourceField label="路径正则" hint="每行一个正则表达式">
<ResourceTextarea
className="min-h-20"
placeholder="^/api/public/"
{...form.register('whitelist.path_regexes')}
/>
</ResourceField>
<ResourceField label="User-Agent" hint="每行一个关键字">
<ResourceTextarea
className="min-h-20"
placeholder="Googlebot&#10;bingbot"
{...form.register('whitelist.user_agents')}
/>
</ResourceField>
</fieldset>
<fieldset disabled={!watchedEnabled} className="space-y-4">
<legend className="text-sm font-medium text-[var(--foreground-primary)] mb-2">
黑名单(匹配的请求必须 PoW)
</legend>
<ResourceField label="IP" hint="每行一个 IP 地址">
<ResourceTextarea
className="min-h-20"
placeholder="1.2.3.4"
{...form.register('blacklist.ips')}
/>
</ResourceField>
<ResourceField label="IP CIDR" hint="每行一个 CIDR 范围">
<ResourceTextarea
className="min-h-20"
placeholder="10.0.0.0/8"
{...form.register('blacklist.ip_cidrs')}
/>
</ResourceField>
<ResourceField label="路径" hint="每行一个路径通配符">
<ResourceTextarea
className="min-h-20"
placeholder="/admin/*"
{...form.register('blacklist.paths')}
/>
</ResourceField>
<ResourceField label="路径正则" hint="每行一个正则表达式">
<ResourceTextarea
className="min-h-20"
placeholder="^/private/"
{...form.register('blacklist.path_regexes')}
/>
</ResourceField>
<ResourceField label="User-Agent" hint="每行一个关键字">
<ResourceTextarea
className="min-h-20"
placeholder="bot&#10;crawler"
{...form.register('blacklist.user_agents')}
/>
</ResourceField>
</fieldset>
</div>
{form.formState.errors.blacklist && (
<p className="text-sm text-[var(--color-danger)]">
{Object.values(form.formState.errors.blacklist)
.flatMap((e) =>
e && typeof e === 'object' && 'message' in e
? [e.message as string]
: [],
)
.join('; ')}
</p>
)}
</form>
</ConfigSectionShell>
);
}
export function ProxyRouteConfigPage({
routeId,
initialSection,
@@ -920,6 +1238,16 @@ export function ProxyRouteConfigPage({
}
/>
) : null}
{currentSection === 'pow' ? (
<PowSection
route={route}
saving={saveMutation.isPending}
onSave={(payload, context) =>
saveMutation.mutate({ payload, context })
}
/>
) : null}
</div>
</div>
</div>
@@ -190,6 +190,8 @@ export function ProxyRouteCreateDrawer({
cache_policy: 'url',
cache_rules: [],
custom_headers: [],
pow_enabled: false,
pow_config: '{}',
remark: values.remark.trim(),
});
},
@@ -25,6 +25,11 @@ export const websiteConfigSections = [
label: '缓存',
description: '配置站点缓存策略。',
},
{
key: 'pow',
label: 'PoW 防护',
description: '配置 Proof-of-Work 反爬虫策略。',
},
] as const;
export type WebsiteConfigSectionKey =
@@ -285,6 +290,8 @@ export function buildPayloadFromRoute(
cache_rules: route.cache_rule_list,
custom_headers: route.custom_header_list,
remark: route.remark || '',
pow_enabled: route.pow_enabled,
pow_config: JSON.stringify(route.pow_config),
...overrides,
};
}
@@ -3,6 +3,23 @@ export interface ProxyRouteCustomHeader {
value: string;
}
export interface ProxyRoutePoWListConfig {
ips: string[];
ip_cidrs: string[];
paths: string[];
path_regexes: string[];
user_agents: string[];
}
export interface ProxyRoutePoWConfig {
difficulty: number;
algorithm: 'fast' | 'slow';
session_ttl: number;
challenge_ttl: number;
whitelist: ProxyRoutePoWListConfig;
blacklist: ProxyRoutePoWListConfig;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
@@ -30,6 +47,8 @@ export interface ProxyRouteItem {
cache_rule_list: string[];
custom_headers: string;
custom_header_list: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: ProxyRoutePoWConfig;
remark: string;
created_at: string;
updated_at: string;
@@ -60,6 +79,8 @@ export interface ProxyRouteMutationPayload {
cache_policy: string;
cache_rules: string[];
custom_headers: ProxyRouteCustomHeader[];
pow_enabled: boolean;
pow_config: string;
remark: string;
}