Merge pull request #1282 from bunkerity/dev

Merge branch "dev" into branch "staging"
This commit is contained in:
Théophile Diot 2024-06-17 18:07:56 +01:00 committed by GitHub
commit 18517b9553
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 183 additions and 115 deletions

View file

@ -330,7 +330,7 @@ Here is the list of related settings :
| --------------------------- | ------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `USE_ANTIBOT` | `no` | multisite | no | Activate antibot feature. |
| `ANTIBOT_URI` | `/challenge` | multisite | no | Unused URI that clients will be redirected to to solve the challenge. |
| `ANTIBOT_RECAPTCHA_SCORE` | `0.7` | multisite | no | Minimum score required for reCAPTCHA challenge. |
| `ANTIBOT_RECAPTCHA_SCORE` | `0.7` | multisite | no | Minimum score required for reCAPTCHA challenge (Only compatible with reCAPTCHA v3). |
| `ANTIBOT_RECAPTCHA_SITEKEY` | | multisite | no | Sitekey for reCAPTCHA challenge. |
| `ANTIBOT_RECAPTCHA_SECRET` | | multisite | no | Secret for reCAPTCHA challenge. |
| `ANTIBOT_HCAPTCHA_SITEKEY` | | multisite | no | Sitekey for hCaptcha challenge. |

View file

@ -65,7 +65,7 @@ Bot detection by using a challenge.
|`ANTIBOT_URI` |`/challenge`|multisite|no |Unused URI that clients will be redirected to to solve the challenge. |
|`ANTIBOT_TIME_RESOLVE` |`60` |multisite|no |Maximum time (in seconds) clients have to resolve the challenge. Once this time has passed, a new challenge will be generated.|
|`ANTIBOT_TIME_VALID` |`86400` |multisite|no |Maximum validity time of solved challenges. Once this time has passed, clients will need to resolve a new one. |
|`ANTIBOT_RECAPTCHA_SCORE` |`0.7` |multisite|no |Minimum score required for reCAPTCHA challenge. |
|`ANTIBOT_RECAPTCHA_SCORE` |`0.7` |multisite|no |Minimum score required for reCAPTCHA challenge (Only compatible with reCAPTCHA v3). |
|`ANTIBOT_RECAPTCHA_SITEKEY`| |multisite|no |Sitekey for reCAPTCHA challenge. |
|`ANTIBOT_RECAPTCHA_SECRET` | |multisite|no |Secret for reCAPTCHA challenge. |
|`ANTIBOT_HCAPTCHA_SITEKEY` | |multisite|no |Sitekey for hCaptcha challenge. |

View file

@ -46,8 +46,12 @@ class Config:
if not server_name:
continue
for variable, value in service.items():
if self._db.is_setting(variable, multisite=True):
config[f"{server_name}_{variable}"] = value
if variable.startswith("CUSTOM_CONF") or not variable.isupper():
continue
if not self._db.is_setting(variable, multisite=True):
self.__logger.warning(f"Variable {variable}: {value} is not a valid multisite setting, ignoring it")
continue
config[f"{server_name}_{variable}"] = value
config["SERVER_NAME"] += f" {server_name}"
config["SERVER_NAME"] = config["SERVER_NAME"].strip()
return config
@ -134,6 +138,7 @@ class Config:
# update instances in database
if "instances" in changes:
self.__logger.debug(f"Updating instances in database: {self.__instances}")
err = self._db.update_instances(self.__instances, changed=False)
if err:
self.__logger.error(f"Failed to update instances: {err}")
@ -141,6 +146,7 @@ class Config:
# save config to database
changed_plugins = []
if "config" in changes:
self.__logger.debug(f"Saving config in database: {self.__config}")
err = self._db.save_config(self.__config, "autoconf", changed=False)
if isinstance(err, str):
success = False
@ -149,6 +155,7 @@ class Config:
# save custom configs to database
if "custom_configs" in changes:
self.__logger.debug(f"Saving custom configs in database: {custom_configs}")
err = self._db.save_custom_configs(custom_configs, "autoconf", changed=False)
if err:
success = False

View file

@ -256,6 +256,15 @@
</div>
<!-- text -->
{-raw-}
<script nonce="{{ nonce_script }}">
// Automatically refresh the page after 2 seconds
setTimeout(() => {
location.reload();
}, 2000);
</script>
{-raw-}
<footer class="fixed bottom-1.5 lg:bottom-2">
<div class="flex justify-center pb-2">
<img

View file

@ -67,18 +67,22 @@ server {
end
local nonce_style = rand(16)
local nonce_script = rand(16)
-- Override CSP header
ngx.header["Content-Security-Policy"] = "default-src 'none'; form-action 'self'; img-src 'self' data:; style-src 'self' 'nonce-"
ngx.header["Content-Security-Policy"] = "default-src 'none'; script-src http: https: 'unsafe-inline' 'strict-dynamic' 'nonce-"
.. nonce_script
.. "'; style-src 'nonce-"
.. nonce_style
.. "'; font-src 'self' data:; base-uri 'self'; require-trusted-types-for 'script';"
.. "'; base-uri 'none'; img-src 'self' data:; font-src 'self' data:; require-trusted-types-for 'script';"
-- Remove server header
ngx.header["Server"] = nil
-- Render template
render("index.html", {
nonce_style = nonce_style
nonce_style = nonce_style,
nonce_script = nonce_script
})
}
}

View file

@ -60,42 +60,36 @@ function antibot:header()
return self:ret(true, "client already resolved the challenge", nil, self.session_data.original_uri)
end
-- Override headers
local header = "Content-Security-Policy"
if self.variables["CONTENT_SECURITY_POLICY_REPORT_ONLY"] == "yes" then
header = header .. "-Report-Only"
end
-- Override CSP header
local csp_directives = {
["default-src"] = "'none'",
["base-uri"] = "'none'",
["img-src"] = "'self' data:",
["font-src"] = "'self' data:",
["script-src"] = "http: https: 'unsafe-inline' 'strict-dynamic' 'nonce-"
.. self.ctx.bw.antibot_nonce_script
.. "'",
["style-src"] = "'self' 'nonce-" .. self.ctx.bw.antibot_nonce_style .. "'",
["require-trusted-types-for"] = "'script'",
}
if self.session_data.type == "recaptcha" then
ngx.header[header] = "default-src 'none'; form-action 'self'; script-src 'strict-dynamic' 'nonce-"
.. self.session_data.nonce_script
.. "' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-inline' http: https:;"
.. " img-src https://www.gstatic.com/recaptcha/ 'self' data:; "
.. " frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"
.. " style-src 'self' 'nonce-"
.. self.session_data.nonce_style
.. "'; font-src 'self' https://fonts.gstatic.com data:; base-uri 'self';"
csp_directives["script-src"] = csp_directives["script-src"]
.. " https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/"
csp_directives["frame-src"] = "https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/"
elseif self.session_data.type == "hcaptcha" then
ngx.header[header] = "default-src 'none'; form-action 'self'; script-src 'strict-dynamic' 'nonce-"
.. self.session_data.nonce_script
.. "' https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' http: https:; img-src 'self' data:;"
.. " frame-src https://hcaptcha.com https://*.hcaptcha.com; style-src 'self' 'nonce-"
.. self.session_data.nonce_style
.. "' https://hcaptcha.com https://*.hcaptcha.com; connect-src https://hcaptcha.com https://*.hcaptcha.com; "
.. " font-src 'self' data:; base-uri 'self';"
csp_directives["script-src"] = csp_directives["script-src"] .. " https://hcaptcha.com https://*.hcaptcha.com"
csp_directives["frame-src"] = "https://hcaptcha.com https://*.hcaptcha.com"
csp_directives["style-src"] = csp_directives["style-src"] .. " https://hcaptcha.com https://*.hcaptcha.com"
csp_directives["connect-src"] = "https://hcaptcha.com https://*.hcaptcha.com"
elseif self.session_data.type == "turnstile" then
ngx.header[header] = "default-src 'none'; form-action 'self'; script-src 'strict-dynamic' 'nonce-"
.. self.session_data.nonce_script
.. "' https://challenges.cloudflare.com 'unsafe-inline' http: https:; img-src 'self' data:;"
.. " frame-src https://challenges.cloudflare.com; style-src 'self' 'nonce-"
.. self.session_data.nonce_style
.. "'; font-src 'self' data:; base-uri 'self';"
else
ngx.header[header] = "default-src 'none'; form-action 'self'; script-src 'strict-dynamic' 'nonce-"
.. self.session_data.nonce_script
.. "' 'unsafe-inline' http: https:; img-src 'self' data:; style-src 'self' 'nonce-"
.. self.session_data.nonce_style
.. "'; font-src 'self' data:; base-uri 'self';"
csp_directives["script-src"] = csp_directives["script-src"] .. " https://challenges.cloudflare.com"
csp_directives["frame-src"] = "https://challenges.cloudflare.com"
end
local csp_content = ""
for directive, value in pairs(csp_directives) do
csp_content = csp_content .. directive .. " " .. value .. "; "
end
ngx.header["Content-Security-Policy"] = csp_content
return self:ret(true, "successfully overridden CSP header")
end
@ -192,6 +186,9 @@ function antibot:content()
return self:ret(true, "no session", nil, "/")
end
self.ctx.bw.antibot_nonce_script = rand(32)
self.ctx.bw.antibot_nonce_style = rand(32)
-- Display content
local ok, err = self:display_challenge()
if not ok then
@ -242,8 +239,6 @@ function antibot:prepare_challenge()
self.session_data.type = self.variables["USE_ANTIBOT"]
self.session_data.resolved = false
self.session_data.original_uri = self.ctx.bw.request_uri
self.session_data.nonce_script = rand(16)
self.session_data.nonce_style = rand(16)
if self.ctx.bw.uri == self.variables["ANTIBOT_URI"] then
self.session_data.original_uri = "/"
end
@ -268,8 +263,8 @@ function antibot:display_challenge()
-- Common variables for templates
local template_vars = {
antibot_uri = self.variables["ANTIBOT_URI"],
nonce_script = self.session_data.nonce_script,
nonce_style = self.session_data.nonce_style,
nonce_script = self.ctx.bw.antibot_nonce_script,
nonce_style = self.ctx.bw.antibot_nonce_style,
}
-- Javascript case
@ -387,7 +382,10 @@ function antibot:check_challenge()
if not ok then
return nil, "error while decoding JSON from reCAPTCHA API : " .. rdata, nil
end
if not rdata.success or rdata.score < tonumber(self.variables["ANTIBOT_RECAPTCHA_SCORE"]) then
if not rdata.success then
return false, "client failed challenge", nil
end
if rdata.score and rdata.score < tonumber(self.variables["ANTIBOT_RECAPTCHA_SCORE"]) then
return false, "client failed challenge with score " .. tostring(rdata.score), nil
end
self.session_data.resolved = true

View file

@ -22,6 +22,65 @@
margin-top: 1rem
}
</style>
{-raw-}
<script
src="https://www.google.com/recaptcha/api.js?onload=onv3Callback&render={*recaptcha_sitekey*}&trustedtypes=true"
nonce="{*nonce_script*}"
></script>
<script
defer
src="https://www.google.com/recaptcha/api.js?onload=onv2Callback&trustedtypes=true"
nonce="{*nonce_script*}"
></script>
<script type="text/javascript" nonce="{*nonce_script*}">
var onSubmit = function (token) {
document.getElementById("token").value = token;
document.getElementById("form").submit();
};
var usedVersion = "v2";
var onv2Callback = function () {
if (usedVersion === "invisible") {
return;
}
// Remove the v3 button
document.getElementById("recaptcha-verify").remove();
// Insert the v2 div
var recaptchaDiv = document.createElement("div");
recaptchaDiv.id = "recaptcha-v2-verify";
recaptchaDiv.className = "g-recaptcha";
recaptchaDiv.setAttribute("data-sitekey", "{*recaptcha_sitekey*}");
recaptchaDiv.setAttribute("data-callback", "onSubmit");
recaptchaDiv.setAttribute("data-action", "submit");
document
.getElementById("recaptcha-container")
.appendChild(recaptchaDiv);
grecaptcha.ready(function () {
grecaptcha.render("recaptcha-v2-verify", {
sitekey: "{*recaptcha_sitekey*}",
callback: onSubmit,
action: "submit",
});
document.querySelector(".grecaptcha-badge").remove();
});
};
var onv3Callback = function () {
usedVersion = "invisible";
grecaptcha.ready(function () {
document
.getElementById("recaptcha-verify")
.addEventListener("click", function () {
grecaptcha.execute();
});
});
};
</script>
{-raw-}
</head>
<body
class="bg-gradient-to-r from-[#075577] to-[#116D70] w-screen h-screen overflow-hidden"
@ -255,14 +314,19 @@
<form class="hidden" method="POST" action="{*antibot_uri*}" id="form">
<input type="hidden" name="token" id="token" />
</form>
{-raw-}
<div class="mt-8 flex flex-col justify-center items-center">
<button
id="recaptcha-verify"
class="text-sm xs:text-base mb-2.5 hover:brightness-90 mt-2 rounded-lg bg-secondary px-6 py-2 text-white font-bold"
>
I'm not a robot
</button>
<div id="recaptcha-container">
<button
id="recaptcha-verify"
class="g-recaptcha text-sm xs:text-base mb-2.5 hover:brightness-90 mt-2 rounded-lg bg-secondary px-6 py-2 text-white font-bold"
data-sitekey="{*recaptcha_sitekey*}"
data-callback="onSubmit"
data-action="submit"
>
I'm not a robot
</button>
</div>
{-raw-}
<p
id="recaptcha-terms"
class="text-gray-100 text-center text-xs sm:text-sm"
@ -284,41 +348,6 @@
</div>
</div>
{-raw-}
<script nonce="{*nonce_script*}">
// recaptcha
const check_robot = function () {
grecaptcha.ready(function () {
grecaptcha
.execute("{*recaptcha_sitekey*}", { action: "recaptcha" })
.then(function (token) {
document.getElementById("token").value = token;
document.getElementById("form").submit();
});
});
};
var cooldown = false;
document
.getElementById("recaptcha-verify")
.addEventListener("click", (e) => {
e.preventDefault();
if (cooldown) return;
cooldown = true;
check_robot();
setTimeout(() => {
cooldown = false;
}, 1500);
});
</script>
<script
async
src="https://www.google.com/recaptcha/api.js?render={*recaptcha_sitekey*}"
nonce="{*nonce_script*}"
></script>
{-raw-}
<!-- text -->
<footer class="fixed bottom-1.5 lg:bottom-2">
<div class="flex justify-center pb-2">

View file

@ -22,7 +22,6 @@
</style>
{-raw-}
<script
async
defer
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
nonce="{*nonce_script*}"

View file

@ -53,9 +53,9 @@
"ANTIBOT_RECAPTCHA_SCORE": {
"context": "multisite",
"default": "0.7",
"help": "Minimum score required for reCAPTCHA challenge.",
"help": "Minimum score required for reCAPTCHA challenge (Only compatible with reCAPTCHA v3).",
"id": "antibot-recaptcha-score",
"label": "reCAPTCHA score",
"label": "reCAPTCHA v3 score",
"regex": "^(0\\.[1-9]|1\\.0)$",
"type": "text"
},

View file

@ -394,9 +394,20 @@ class Database:
"""Check if the setting exists in the database and optionally if it's multisite"""
with self.__db_session() as session:
try:
if multisite:
return session.query(Settings).filter_by(id=setting, context="multisite").first() is not None
return session.query(Settings).filter_by(id=setting).first() is not None
multiple = False
if self.suffix_rx.search(setting):
setting = setting.rsplit("_", 1)[0]
multiple = True
db_setting = session.query(Settings).filter_by(id=setting).first()
if not db_setting:
return False
elif multisite and db_setting.context != "multisite":
return False
elif multiple and db_setting.multiple is None:
return False
return True
except (ProgrammingError, OperationalError):
return False
@ -1575,25 +1586,24 @@ class Database:
if global_value.context == "multisite":
multisite.add(setting_id)
is_multisite = config.get("MULTISITE", {"value": "no"})["value"] == "yes" if methods else config.get("MULTISITE", "no") == "yes"
services = session.query(Services).with_entities(Services.id, Services.is_draft)
if not with_drafts:
services = services.filter_by(is_draft=False)
servers = ""
for service in services:
if not global_only:
if not global_only and is_multisite:
servers = ""
for service in services:
config[f"{service.id}_IS_DRAFT"] = "yes" if service.is_draft else "no"
if methods:
config[f"{service.id}_IS_DRAFT"] = {"value": config[f"{service.id}_IS_DRAFT"], "global": False, "method": "default"}
for key in multisite:
config[f"{service.id}_{key}"] = config[key]
servers += f"{service.id} "
servers = servers.strip()
servers += f"{service.id} "
servers = servers.strip()
config["SERVER_NAME"] = servers if not methods else {"value": servers, "global": True, "method": "default"}
if not global_only and (config.get("MULTISITE", {"value": "no"})["value"] == "yes" if methods else config.get("MULTISITE", "no") == "yes"):
# Define the join operation
j = join(Services, Services_settings, Services.id == Services_settings.service_id)
j = j.join(Settings, Settings.id == Services_settings.setting_id)
@ -1632,6 +1642,10 @@ class Database:
config[f"{result.service_id}_{result.setting_id}" + (f"_{result.suffix}" if result.multiple and result.suffix else "")] = (
value if not methods else {"value": value, "global": False, "method": result.method}
)
else:
servers = " ".join(service.id for service in services)
config["SERVER_NAME"] = servers if not methods else {"value": servers, "global": True, "method": "default"}
return config

View file

@ -165,6 +165,15 @@ def send_nginx_custom_configs(sent_path: Path = CUSTOM_CONFIGS_PATH):
logger.info(f"Successfully sent {sent_path} folder")
def send_nginx_external_plugins(sent_path: Path = EXTERNAL_PLUGINS_PATH):
assert SCHEDULER is not None, "SCHEDULER is not defined"
logger.info(f"Sending {sent_path} folder ...")
if not SCHEDULER.send_files(sent_path.as_posix(), "/pro_plugins" if sent_path.as_posix().endswith("/pro/plugins") else "/plugins"):
logger.error(f"Error while sending {sent_path} folder")
else:
logger.info(f"Successfully sent {sent_path} folder")
def listen_for_instances_reload():
from docker import DockerClient
@ -236,15 +245,14 @@ def generate_custom_configs(configs: Optional[List[Dict[str, Any]]] = None, *, o
send_nginx_custom_configs(original_path)
def generate_external_plugins(plugins: Optional[List[Dict[str, Any]]] = None, *, original_path: Union[Path, str] = EXTERNAL_PLUGINS_PATH):
def generate_external_plugins(original_path: Union[Path, str] = EXTERNAL_PLUGINS_PATH):
if not isinstance(original_path, Path):
original_path = Path(original_path)
pro = "pro" in original_path.parts
pro = original_path.as_posix().endswith("/pro/plugins")
if not plugins:
assert SCHEDULER is not None
plugins = SCHEDULER.db.get_plugins(_type="pro" if pro else "external", with_data=True)
assert plugins is not None, "Couldn't get plugins from database"
assert SCHEDULER is not None
plugins = SCHEDULER.db.get_plugins(_type="pro" if pro else "external", with_data=True)
assert plugins is not None, "Couldn't get plugins from database"
# Remove old external/pro plugins files
logger.info(f"Removing old/changed {'pro ' if pro else ''}external plugins files ...")
@ -299,10 +307,7 @@ def generate_external_plugins(plugins: Optional[List[Dict[str, Any]]] = None, *,
if SCHEDULER and SCHEDULER.apis:
logger.info(f"Sending {'pro ' if pro else ''}external plugins to BunkerWeb")
ret = SCHEDULER.send_files(original_path, "/pro_plugins" if original_path.as_posix().endswith("/pro/plugins") else "/plugins")
if not ret:
logger.error(f"Sending {'pro ' if pro else ''}external plugins failed, configuration will not work as expected...")
send_nginx_external_plugins(original_path)
def generate_caches():
@ -388,7 +393,7 @@ def run_in_slave_mode():
threads = [
Thread(target=generate_custom_configs),
Thread(target=generate_external_plugins),
Thread(target=generate_external_plugins, kwargs={"original_path": PRO_PLUGINS_PATH}),
Thread(target=generate_external_plugins, args=(PRO_PLUGINS_PATH,)),
Thread(target=generate_caches),
]
@ -613,6 +618,7 @@ if __name__ == "__main__":
| ({"jobs": jobs} if jobs else {})
)
changes = False
if tmp_external_plugins:
changes = {hash(dict_to_frozenset(d)) for d in tmp_external_plugins} != {hash(dict_to_frozenset(d)) for d in db_plugins}
@ -623,8 +629,10 @@ if __name__ == "__main__":
logger.error(f"Couldn't save some manually added {_type} plugins to database: {err}")
except BaseException as e:
logger.error(f"Error while saving {_type} plugins to database: {e}")
else:
return send_nginx_external_plugins(plugin_path)
generate_external_plugins(SCHEDULER.db.get_plugins(_type=_type, with_data=True), original_path=plugin_path)
generate_external_plugins(plugin_path)
threads.extend([Thread(target=check_plugin_changes, args=("external",)), Thread(target=check_plugin_changes, args=("pro",))])
@ -648,7 +656,7 @@ if __name__ == "__main__":
threads.clear()
if changes["pro_plugins_changed"]:
threads.append(Thread(target=generate_external_plugins, kwargs={"original_path": PRO_PLUGINS_PATH}))
threads.append(Thread(target=generate_external_plugins, args=(PRO_PLUGINS_PATH,)))
if changes["external_plugins_changed"]:
threads.append(Thread(target=generate_external_plugins))
@ -959,12 +967,12 @@ if __name__ == "__main__":
if PLUGINS_NEED_GENERATION:
CHANGES.append("external_plugins")
generate_external_plugins(SCHEDULER.db.get_plugins(_type="external", with_data=True))
generate_external_plugins()
SCHEDULER.update_jobs()
if PRO_PLUGINS_NEED_GENERATION:
CHANGES.append("pro_plugins")
generate_external_plugins(SCHEDULER.db.get_plugins(_type="pro", with_data=True), original_path=PRO_PLUGINS_PATH)
generate_external_plugins(PRO_PLUGINS_PATH)
SCHEDULER.update_jobs()
if CONFIG_NEED_GENERATION: