Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
6fc701b
feat: add context7 configuration file with URL and public key
TheophileDiot Mar 6, 2026
ab24f60
Revert "feat: add context7 configuration file with URL and public key"
TheophileDiot Mar 6, 2026
230f840
use OCSP files for stapling responses
mkf-sysangels Mar 12, 2026
80236ed
Update ssl-certificate-lua.conf
mkf-sysangels Mar 12, 2026
6d75d8a
remove redis integration
mkf-sysangels Mar 12, 2026
d444061
fix "end" entry
mkf-sysangels Mar 12, 2026
18b58de
fix tab errors
mkf-sysangels Mar 12, 2026
a731fe8
Add a 3 days TTL cache for OCSP responses
mkf-sysangels Mar 13, 2026
05c6926
add customcert processing + refresh TTL
mkf-sysangels Mar 13, 2026
f824b41
fix cleanup run, fix job run
mkf-sysangels Mar 13, 2026
3762f36
use python cryptography + add cleanup + SAN fix + fix race condition
mkf-sysangels Mar 13, 2026
a4b10af
add checksum checking, cleanup function
mkf-sysangels Mar 13, 2026
55df73d
Trigger OCSP stapling refresh
mkf-sysangels Mar 13, 2026
d6ee173
OCSP Response Signature is NOT Cryptographically Verified
mkf-sysangels Mar 13, 2026
6c60a9a
fix multiple vulnerability issues
mkf-sysangels Mar 13, 2026
5fba3f4
def is_safe_url
mkf-sysangels Mar 13, 2026
6b5bef6
Update ocsp-refresh.py
mkf-sysangels Mar 13, 2026
981178e
Update ocsp-refresh.py
mkf-sysangels Mar 13, 2026
ffa38d7
add locking, error handling, verification
mkf-sysangels Mar 13, 2026
1321d90
fix tlinter errors
mkf-sysangels Mar 13, 2026
426cc52
add rate limiting
mkf-sysangels Mar 13, 2026
3d259d4
database: batch inserts
mkf-sysangels Mar 13, 2026
bfc6869
trigger OCSP update
mkf-sysangels Mar 13, 2026
bc2da87
improve caching logic
mkf-sysangels Mar 13, 2026
a860bf5
shorter TTL for caching
mkf-sysangels Mar 13, 2026
7f92fea
clean up expired OCSP entries
mkf-sysangels Mar 13, 2026
3857c04
fix linter hints
mkf-sysangels Mar 13, 2026
1fe1087
Merge pull request #3321 from bunkerity/dev
TheophileDiot Mar 13, 2026
1c57d48
fixing _get_cert_checksums errors
mkf-sysangels Mar 13, 2026
7e933c1
optimizing execution time
mkf-sysangels Mar 13, 2026
eac3511
fix: Trigger OCSP stapling refresh for newly issued certificates
mkf-sysangels Mar 13, 2026
3a793b5
add wildcard support
mkf-sysangels Mar 13, 2026
1675847
Update ssl-certificate-lua.conf
mkf-sysangels Mar 13, 2026
81fac61
Update ssl-certificate-lua.conf
mkf-sysangels Mar 13, 2026
00aeb9e
Update ocsp-refresh.py
mkf-sysangels Mar 13, 2026
2748e0e
Update ocsp-refresh.py
mkf-sysangels Mar 13, 2026
7ce0e7e
Update ocsp-refresh.py
mkf-sysangels Mar 13, 2026
273279c
fix wildcard error
mkf-sysangels Mar 13, 2026
4e9af34
fix merge issue
mkf-sysangels Mar 13, 2026
7bd3c3f
Merge branch 'dev' into OCSP-SSL-Stappling-#1592
mkf-sysangels Mar 13, 2026
787e8e6
Merge remote-tracking branch 'upstream/master' into OCSP-SSL-Stapplin…
mkf-sysangels Mar 14, 2026
184f7bb
Update ssl-certificate-lua.conf
mkf-sysangels Mar 14, 2026
8b8c546
Potential fix for pull request finding
mkf-sysangels Mar 15, 2026
b735de9
Address Copilot review: OCSP timeouts, SSL_USE_OCSP_STAPLING, stats s…
mkf-sysangels Mar 15, 2026
1841a32
Update ssl-certificate-lua.conf
mkf-sysangels Mar 15, 2026
bee29ca
fix OCSP certificate verification + optimize log level
mkf-sysangels Mar 15, 2026
3b3ac2f
fallback to tls on error
mkf-sysangels Mar 15, 2026
94c4d52
fix ocsp loading error
mkf-sysangels Mar 15, 2026
7616e4b
fix SN comparison, check OCSP existence early, fallback to native tls…
mkf-sysangels Mar 16, 2026
edd608c
add redis backend
mkf-sysangels Mar 16, 2026
2fba405
fix openssl file race condition
mkf-sysangels Mar 16, 2026
c196df2
add fail safe
mkf-sysangels Mar 16, 2026
eb5fe1f
use safe module loading + implement OCSP-must-stapple + remove redis
mkf-sysangels Mar 16, 2026
4a1b226
add --force-fetch + cleanup
mkf-sysangels Mar 16, 2026
c7de21a
optimize lookup mechanism
mkf-sysangels Mar 19, 2026
b916f35
code fixes
mkf-sysangels Mar 19, 2026
b8814b3
fix path issue
mkf-sysangels Mar 19, 2026
4259817
fix closing section error
mkf-sysangels Mar 19, 2026
1a6aab5
fix key mapping
mkf-sysangels Mar 19, 2026
5309d9b
fix calc of fingerprints
mkf-sysangels Mar 19, 2026
697130a
optimize ocsp validation/lookup+ verbose logging
mkf-sysangels Mar 20, 2026
0e2d186
optimize orphan cleanup + hash colision check
mkf-sysangels Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,979 changes: 1,904 additions & 75 deletions src/common/confs/server-http/ssl-certificate-lua.conf

Large diffs are not rendered by default.

35 changes: 33 additions & 2 deletions src/common/core/customcert/customcert.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ local get_multiple_variables = utils.get_multiple_variables
local has_variable = utils.has_variable
local read_files = utils.read_files

-- Convert binary data to lowercase hex (replacement for ngx.encode_base16)
local function to_hex(bin)
if not bin then
return nil
end
local t = {}
for i = 1, #bin do
t[i] = string.format("%02x", string.byte(bin, i))
end
return table.concat(t)
end

function customcert:initialize(ctx)
-- Call parent initialize
plugin.initialize(self, "customcert", ctx)
Expand Down Expand Up @@ -122,11 +134,30 @@ function customcert:load_data(data, server_name)
if not priv_key then
return false, "error while parsing pem priv key : " .. err
end
-- Cache data

-- Pre-compute leaf certificate fingerprint for OCSP lookup (same strategy as letsencrypt)
-- This avoids relying on runtime PEM parsing inside `ssl-certificate-lua.conf`.
local cert_fingerprint = nil
pcall(function()
local x509 = require("resty.openssl.x509")
-- Keep in sync with letsencrypt.lua regex: take the first cert PEM block.
local leaf_pem = data[1]:match("(%-%-%-%-BEGIN CERTIFICATE%-%-%-%-.-%-%-%-%-END CERTIFICATE%-%-%-%-)")
if leaf_pem then
local cert_obj = x509.new(leaf_pem)
if cert_obj then
local digest_bytes = cert_obj:pubkey_digest("sha256")
if digest_bytes then
cert_fingerprint = to_hex(digest_bytes):lower()
end
end
end
end)

-- Cache data (preserve original PEM strings at indices 3,4 for OCSP fingerprinting)
for key in server_name:gmatch("%S+") do
local cache_key = "plugin_customcert_" .. key
local ok
ok, err = self.internalstore:set(cache_key, { cert_chain, priv_key }, nil, true)
ok, err = self.internalstore:set(cache_key, { cert_chain, priv_key, data[1], data[2], cert_fingerprint }, nil, true)
if not ok then
return false, "error while setting data into internalstore : " .. err
end
Expand Down
28 changes: 27 additions & 1 deletion src/common/core/customcert/jobs/custom-cert.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from os import getenv, sep
from os.path import join
from pathlib import Path
from subprocess import DEVNULL, run
from subprocess import DEVNULL, TimeoutExpired, run
from sys import exit as sys_exit, path as sys_path
from base64 import b64decode
from tempfile import NamedTemporaryFile
Expand All @@ -20,6 +20,7 @@

LOGGER = getLogger("CUSTOM-CERT")
JOB = Job(LOGGER, __file__)
OCSP_REFRESH_TIMEOUT = 2100 # 35 minutes max to cover ocsp-refresh job worst-case duration


def process_ssl_data(data: str, file_path: Optional[str], data_type: Literal["cert", "key"], server_name: str) -> Union[bytes, Path, None]:
Expand Down Expand Up @@ -159,6 +160,7 @@ def check_cert(cert_file: Union[Path, bytes], key_file: Union[Path, bytes], firs
sys_exit(0)

skipped_servers = []
changed_domains = [] # Track which domains had certificate changes
if not multisite:
all_domains = [all_domains[0]]
if getenv("USE_CUSTOM_SSL", "no") == "no":
Expand Down Expand Up @@ -210,6 +212,7 @@ def check_cert(cert_file: Union[Path, bytes], key_file: Union[Path, bytes], firs
continue
elif need_reload:
LOGGER.info(f"Detected change in {first_server}'s certificate")
changed_domains.append(first_server) # Track this domain as changed
status = 1
continue

Expand All @@ -218,6 +221,29 @@ def check_cert(cert_file: Union[Path, bytes], key_file: Union[Path, bytes], firs
for first_server in skipped_servers:
JOB.del_cache("cert.pem", service_id=first_server)
JOB.del_cache("key.pem", service_id=first_server)

# Trigger OCSP stapling refresh when certificates changed (AFTER caching)
# OCSP job will compare new certs with cached ones and process differential updates
if changed_domains and getenv("SSL_USE_OCSP_STAPLING", "yes").lower() == "yes":
LOGGER.info(f"🔄 OCSP triggering refresh for {len(changed_domains)} changed custom cert(s): {', '.join(changed_domains)}")
try:
import sys

ocsp_script = join(sep, "usr", "share", "bunkerweb", "core", "ssl", "jobs", "ocsp-refresh.py")
result = run([sys.executable, ocsp_script, "--force"], stdin=DEVNULL, capture_output=True, text=True, timeout=OCSP_REFRESH_TIMEOUT)
if result.returncode == 0:
LOGGER.info("✓ OCSP refresh completed successfully after cert change")
else:
LOGGER.warning(f"⚠️ OCSP refresh returned exit code {result.returncode}")
if result.stderr:
for line in result.stderr.strip().splitlines():
LOGGER.debug(f"OCSP: {line}")
except TimeoutExpired as e:
LOGGER.warning(
f"⚠️ OCSP post-change refresh timed out after {OCSP_REFRESH_TIMEOUT}s (non-fatal): {e}"
)
except Exception as e:
LOGGER.warning(f"⚠️ OCSP post-change refresh failed (non-fatal): {e}")
except SystemExit as e:
status = e.code
except BaseException as e:
Expand Down
28 changes: 26 additions & 2 deletions src/common/core/letsencrypt/jobs/certbot-new.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from re import MULTILINE, match, search
from select import select
from subprocess import DEVNULL, PIPE, STDOUT, Popen, run
from subprocess import DEVNULL, PIPE, STDOUT, Popen, TimeoutExpired, run
from sys import exit as sys_exit, path as sys_path
from time import monotonic, sleep
from threading import Event, Lock, Thread
Expand Down Expand Up @@ -127,6 +127,7 @@ def stop_progress_monitor() -> None:
ACME_SERVER_TYPES = ("letsencrypt", "zerossl")
DNS_PROPAGATION_DEFAULT = "default"
CERTBOT_TIMEOUT = 900 # 15 minutes max for a single certbot invocation
OCSP_REFRESH_TIMEOUT = 2100 # 35 minutes max to cover ocsp-refresh job worst-case duration


def normalize_server_names(server_names: str) -> Set[str]:
Expand Down Expand Up @@ -1141,13 +1142,36 @@ def generate_certificate(service: str, config: Dict[str, Union[str, bool, int, D

save_zerossl_api_key_hashes(updated_zerossl_api_key_hashes)

# * Save data to db cache
# * Save data to db cache (full LE directory)
if DATA_PATH.is_dir() and list(DATA_PATH.iterdir()):
cached, err = JOB.cache_dir(DATA_PATH)
if not cached:
LOGGER.error(f"Error while saving data to db cache : {err}")
else:
LOGGER.info("Successfully saved data to db cache")

# * Trigger OCSP stapling refresh for newly issued certificates (AFTER database save)
# OCSP job will compare new certs with cached ones and process differential updates
if status == 1 and getenv("SSL_USE_OCSP_STAPLING", "yes").lower() == "yes":
LOGGER.info("🔄 OCSP triggering refresh for newly issued certificates")
try:
import sys

ocsp_script = join(sep, "usr", "share", "bunkerweb", "core", "ssl", "jobs", "ocsp-refresh.py")
result = run([sys.executable, ocsp_script, "--force"], stdin=DEVNULL, capture_output=True, text=True, timeout=OCSP_REFRESH_TIMEOUT)
if result.returncode == 0:
LOGGER.info("✓ OCSP refresh completed successfully after issuance")
else:
LOGGER.warning(f"⚠️ OCSP refresh returned exit code {result.returncode}")
if result.stderr:
for line in result.stderr.strip().splitlines():
LOGGER.debug(f"OCSP: {line}")
except TimeoutExpired as e:
LOGGER.warning(
f"⚠️ OCSP post-issuance refresh timed out after {OCSP_REFRESH_TIMEOUT}s (non-fatal): {e}"
)
except Exception as e:
LOGGER.warning(f"⚠️ OCSP post-issuance refresh failed (non-fatal): {e}")
except SystemExit as e:
status = e.code
except BaseException as e:
Expand Down
62 changes: 47 additions & 15 deletions src/common/core/letsencrypt/jobs/certbot-renew.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

from os import getenv, sep
from os.path import join
from subprocess import DEVNULL, PIPE, Popen
from subprocess import DEVNULL, PIPE, Popen, TimeoutExpired, run
from sys import exit as sys_exit, path as sys_path
from time import monotonic
from traceback import format_exc

for deps_path in [join(sep, "usr", "share", "bunkerweb", *paths) for paths in (("deps", "python"), ("utils",), ("db",))]:
Expand All @@ -29,9 +28,11 @@
LOGGER = getLogger("LETS-ENCRYPT.RENEW")

LOGGER_CERTBOT = getLogger("LETS-ENCRYPT.RENEW.CERTBOT")
CERTBOT_TIMEOUT = 900 # 15 minutes max for a single certbot invocation
CERTBOT_TIMEOUT = 900 # 900 seconds (15 minutes) max for a single certbot invocation
OCSP_REFRESH_TIMEOUT = 2100 # 2100 seconds (35 minutes) max to cover ocsp-refresh job worst-case duration
status = 0


try:
# Check if we're using let's encrypt
use_letsencrypt = False
Expand Down Expand Up @@ -81,33 +82,64 @@
]
+ (["-v"] if getenv("CUSTOM_LOG_LEVEL", getenv("LOG_LEVEL", "INFO")).upper() == "DEBUG" else []),
stdin=DEVNULL,
stdout=PIPE,
stderr=PIPE,
universal_newlines=True,
env=cmd_env,
)
deadline = monotonic() + CERTBOT_TIMEOUT
while process.poll() is None:
if monotonic() > deadline:
LOGGER.error(f"certbot renew timed out after {CERTBOT_TIMEOUT}s, killing process.")
process.kill()
process.wait()
status = 2
break
if process.stderr:
for line in process.stderr:
LOGGER_CERTBOT.info(line.strip())
try:
stdout, stderr = process.communicate(timeout=CERTBOT_TIMEOUT)
except TimeoutExpired:
Comment thread
mkf-sysangels marked this conversation as resolved.
LOGGER.error(f"certbot renew timed out after {CERTBOT_TIMEOUT}s, killing process.")
process.kill()
stdout, stderr = process.communicate()
status = 2
if stdout:
for line in stdout.splitlines():
line_str = line.strip()
if line_str:
LOGGER_CERTBOT.info(line_str)
if "(success)" in line_str or "Congratulations" in line_str:
status = 1
if stderr:
for line in stderr.splitlines():
LOGGER_CERTBOT.info(line.strip())

if process.returncode and process.returncode != 0:
status = 2
LOGGER.error("Certificates renewal failed")

# Save Let's Encrypt data to db cache
# Save Let's Encrypt data to db cache (full directory)
if DATA_PATH.is_dir() and list(DATA_PATH.iterdir()):
cached, err = JOB.cache_dir(DATA_PATH)
if not cached:
LOGGER.error(f"Error while saving Let's Encrypt data to db cache : {err}")
else:
LOGGER.info("Successfully saved Let's Encrypt data to db cache")

# Trigger OCSP refresh after successful renewal (AFTER database save)
# OCSP job will compare new certs with cached ones and process differential updates
if status == 1 and getenv("SSL_USE_OCSP_STAPLING", "yes").lower() == "yes":
LOGGER.info("🔄 OCSP triggering refresh for renewed certificates")

try:
import sys

ocsp_script = join(sep, "usr", "share", "bunkerweb", "core", "ssl", "jobs", "ocsp-refresh.py")
result = run([sys.executable, ocsp_script, "--force"], stdin=DEVNULL, capture_output=True, text=True, timeout=OCSP_REFRESH_TIMEOUT)
if result.returncode == 0:
Comment thread
mkf-sysangels marked this conversation as resolved.
LOGGER.info("✓ OCSP refresh completed successfully after renewal")
else:
LOGGER.warning(f"⚠️ OCSP refresh returned exit code {result.returncode}")
if result.stderr:
for line in result.stderr.strip().splitlines():
LOGGER.debug(f"OCSP: {line}")
except TimeoutExpired as e:
LOGGER.warning(
f"⚠️ OCSP post-renewal refresh timed out after {OCSP_REFRESH_TIMEOUT}s (non-fatal): {e}"
)
except Exception as e:
LOGGER.warning(f"⚠️ OCSP post-renewal refresh failed (non-fatal): {e}")
except SystemExit as e:
status = e.code
except BaseException as e:
Expand Down
31 changes: 29 additions & 2 deletions src/common/core/letsencrypt/letsencrypt.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ local sort = table.sort
local lower = string.lower
local gsub = string.gsub

-- Convert binary data to lowercase hex (replacement for ngx.encode_base16)
local function to_hex(bin)
if not bin then
return nil
end
local t = {}
for i = 1, #bin do
t[i] = string.format("%02x", string.byte(bin, i))
end
return table.concat(t)
end

-- Mirror certbot-new wildcard grouping so certificate identifiers stay in sync.
local function sanitize_domain_labels(domain)
if not domain or domain == "" then
Expand Down Expand Up @@ -485,11 +497,26 @@ function letsencrypt:load_data(data, server_name)
if not priv_key then
return false, "error while parsing pem priv key : " .. err
end
-- Cache data
-- Pre-compute leaf certificate fingerprint for OCSP lookup (avoids PEM parsing on every TLS handshake)
local cert_fingerprint = nil
pcall(function()
local x509 = require("resty.openssl.x509")
local leaf_pem = data[1]:match("(%-%-%-%-%-BEGIN CERTIFICATE%-%-%-%-%-.-%-%-%-%-%-END CERTIFICATE%-%-%-%-%-)")
if leaf_pem then
local cert_obj = x509.new(leaf_pem)
if cert_obj then
local digest_bytes = cert_obj:pubkey_digest("sha256")
if digest_bytes then
cert_fingerprint = to_hex(digest_bytes):lower()
end
end
end
end)
-- Cache data: {parsed_cert, parsed_key, cert_pem, key_pem, fingerprint}
for key in server_name:gmatch("%S+") do
local cache_key = "plugin_letsencrypt_" .. key
local ok
ok, err = self.internalstore:set(cache_key, { cert_chain, priv_key }, nil, true)
ok, err = self.internalstore:set(cache_key, { cert_chain, priv_key, data[1], data[2], cert_fingerprint }, nil, true)
if not ok then
return false, "error while setting data into internalstore : " .. err
end
Expand Down
Loading