Author SHA1 Message Date
klemek d9f3950c1a chore: 1.6.0
Python Lint CI / ruff (push) Successful in 1m46s
Docker CI / docker-build (push) Successful in 2m7s
Python Lint CI / ruff-format-check (push) Successful in 1m10s
Python Lint CI / ty (push) Successful in 55s
Python Test CI / coverage (push) Successful in 3m44s
2026-09-26 11:26:08 +02:00
klemek d8deab8720 feat(security): servername debounce 2026-09-26 11:25:20 +02:00
klemek a8943a78dc chore: 1.5.3
Python Lint CI / ruff (push) Successful in 2m8s
Python Lint CI / ruff-format-check (push) Successful in 2m30s
Docker CI / docker-build (push) Successful in 4m55s
Python Lint CI / ty (push) Successful in 2m2s
Python Test CI / coverage (push) Successful in 2m57s
2026-07-17 16:03:33 +02:00
klemek 118daf1e8c feat: remove default favicon
Docker CI / docker-build (push) Has been cancelled
Python Lint CI / ruff-format-check (push) Has been cancelled
Python Lint CI / ty (push) Has been cancelled
Python Lint CI / ruff (push) Has been cancelled
Python Test CI / coverage (push) Has been cancelled
2026-07-17 16:03:18 +02:00
klemek 5b4cba44d0 tools(make): release target 2026-06-25 14:34:25 +02:00
9 changed files with 43 additions and 11 deletions
+8
View File
@@ -130,3 +130,11 @@ docker-build: ## docker build
.PHONY: docker-run .PHONY: docker-run
docker-run: docker-build ## docker run docker-run: docker-build ## docker run
@$(DOCKER) run -it -p $(PORT):80 -v ./data:/data $(DOCKER_TAG) --debug --no-certbot --no-https --host localhost:$(PORT) run @$(DOCKER) run -it -p $(PORT):80 -v ./data:/data $(DOCKER_TAG) --debug --no-certbot --no-https --host localhost:$(PORT) run
.PHONY: release-%
release-%: .venv ## release major/minor/patch
$(UV) version --bump=$*
git add pyproject.toml uv.lock
git commit -m "chore: $$(uv version --short)"
git tag "release/$$(uv version --short)" -m "$$(uv version)"
$(UV) build
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "stapler" name = "stapler"
version = "1.5.2" version = "1.6.0"
description = "Static pages as simple as a gzip file" description = "Static pages as simple as a gzip file"
requires-python = ">=3.14" requires-python = ">=3.14"
dependencies = [ dependencies = [
+13 -3
View File
@@ -1,3 +1,4 @@
import datetime
import logging import logging
import pathlib import pathlib
import shutil import shutil
@@ -21,13 +22,14 @@ class CertManager:
__slots__ = [ __slots__ = [
"certbot_conf", "certbot_conf",
"certbot_www", "certbot_www",
"last_file_change", "last_servername",
"logger", "logger",
"self_signed_path", "self_signed_path",
"with_certbot", "with_certbot",
] ]
SELF_SIGNED_DAYS = 30 SELF_SIGNED_DAYS = 30
DEBOUCE_SECONDS = 5
CRT_FILE = "fullchain.pem" CRT_FILE = "fullchain.pem"
KEY_FILE = "privkey.pem" KEY_FILE = "privkey.pem"
@@ -37,7 +39,7 @@ class CertManager:
self.certbot_www: pathlib.Path = pathlib.Path(params.certbot_www) self.certbot_www: pathlib.Path = pathlib.Path(params.certbot_www)
self.self_signed_path: pathlib.Path = pathlib.Path(params.self_signed_path) self.self_signed_path: pathlib.Path = pathlib.Path(params.self_signed_path)
self.with_certbot: bool = params.with_certbot self.with_certbot: bool = params.with_certbot
self.last_file_change: int | float = 0 self.last_servername: datetime.datetime = datetime.datetime.now(tz=datetime.UTC)
def init(self) -> None: def init(self) -> None:
self.logger.debug("Initializing...") self.logger.debug("Initializing...")
@@ -75,6 +77,12 @@ class CertManager:
return True return True
return created or self.__create_self_signed(host) return created or self.__create_self_signed(host)
def debounce(self) -> bool:
last = self.last_servername
now = datetime.datetime.now(tz=datetime.UTC)
self.last_servername = now
return (now - last).total_seconds() > self.DEBOUCE_SECONDS
def get_cert(self, host: str) -> pathlib.Path: def get_cert(self, host: str) -> pathlib.Path:
if self.__exists_certbot(host): if self.__exists_certbot(host):
return self.__certbot_file(host, self.CRT_FILE) return self.__certbot_file(host, self.CRT_FILE)
@@ -211,7 +219,9 @@ class CertManager:
return None return None
self.logger.debug("servername callback: %s", host) self.logger.debug("servername callback: %s", host)
if not self.exists(host) and ( if not self.exists(host) and (
not self.valid_host(host) or not self.create_or_update(host) not self.debounce()
or not self.valid_host(host)
or not self.create_or_update(host)
): ):
return None return None
cert_file = self.get_cert(host) cert_file = self.get_cert(host)
+1 -1
View File
@@ -16,7 +16,7 @@ class DataDir:
] ]
PATH_REGEX = re.compile(r"^[\w-]+$") PATH_REGEX = re.compile(r"^[\w-]+$")
NEEDED_FILES: typing.ClassVar[list[str]] = ["favicon.ico", "robots.txt"] NEEDED_FILES: typing.ClassVar[list[str]] = ["robots.txt"]
def __init__(self, root_path: str) -> None: def __init__(self, root_path: str) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -294,7 +294,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler):
UPDATE_PATH_REGEX = re.compile(r"^\/([\w-]+)\/?$") UPDATE_PATH_REGEX = re.compile(r"^\/([\w-]+)\/?$")
GET_PATH_REGEX = re.compile(r"^\/([\w-]+)($|\/)") GET_PATH_REGEX = re.compile(r"^\/([\w-]+)($|\/)")
HOST_PART_REGEX = re.compile(r"^([a-z0-9]|[a-z0-9][a-z0-9-]{,61}[a-z0-9])$") HOST_PART_REGEX = re.compile(r"^([a-z0-9]|[a-z0-9][a-z0-9-]{,61}[a-z0-9])$")
AUTHORIZED_PATHS: typing.ClassVar[list[str]] = ["/favicon.ico", "/robots.txt"] AUTHORIZED_PATHS: typing.ClassVar[list[str]] = ["/robots.txt"]
TOKEN_HEADER = "X-Token" # noqa: S105 TOKEN_HEADER = "X-Token" # noqa: S105
HOST_HEADER = "X-Host" HOST_HEADER = "X-Host"
HOST_ONLY_HEADER = "X-Host-Only" HOST_ONLY_HEADER = "X-Host-Only"
+14
View File
@@ -1,3 +1,4 @@
import datetime
import logging import logging
import ssl import ssl
import subprocess import subprocess
@@ -27,6 +28,9 @@ class TestRegistry(BaseTestCase):
) )
) )
self.cert_manager.logger = unittest.mock.Mock(logging.Logger) self.cert_manager.logger = unittest.mock.Mock(logging.Logger)
self.cert_manager.last_servername = datetime.datetime.now(
tz=datetime.UTC
) - datetime.timedelta(seconds=self.cert_manager.DEBOUCE_SECONDS)
self.context_mock = unittest.mock.Mock(ssl.SSLContext) self.context_mock = unittest.mock.Mock(ssl.SSLContext)
self.socket_mock = unittest.mock.Mock(ssl.SSLObject) self.socket_mock = unittest.mock.Mock(ssl.SSLObject)
unittest.mock.patch("subprocess.check_output") unittest.mock.patch("subprocess.check_output")
@@ -183,6 +187,16 @@ class TestRegistry(BaseTestCase):
self.socket_mock, "example.fr", self.context_mock self.socket_mock, "example.fr", self.context_mock
) )
def test_servername_callback_fail_debouce(self) -> None:
self._make_self_signed("example.com")
self.cert_manager.last_servername = datetime.datetime.now(tz=datetime.UTC)
with (
self.patch("ssl.create_default_context", count=0),
):
self.cert_manager.servername_callback(
self.socket_mock, "example.fr", self.context_mock
)
def test_servername_callback_fail_no_binaries(self) -> None: def test_servername_callback_fail_no_binaries(self) -> None:
self._make_self_signed("example.com") self._make_self_signed("example.com")
response = requests.Response() response = requests.Response()
+4 -4
View File
@@ -1227,12 +1227,12 @@ class TestRequestHandler(BaseHandlerTestCase):
with ( with (
self.patch_call( self.patch_call(
"http.server.SimpleHTTPRequestHandler.translate_path", "http.server.SimpleHTTPRequestHandler.translate_path",
["/favicon.ico"], ["/robots.txt"],
), ),
self.seal_mocks(), self.seal_mocks(),
): ):
self.assertEqual( self.assertEqual(
handler.translate_path("/favicon.ico"), handler.translate_path("/robots.txt"),
None, None,
) )
@@ -1269,12 +1269,12 @@ class TestRequestHandler(BaseHandlerTestCase):
self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")),
self.patch_call( self.patch_call(
"http.server.SimpleHTTPRequestHandler.translate_path", "http.server.SimpleHTTPRequestHandler.translate_path",
["/favicon.ico"], ["/robots.txt"],
), ),
self.seal_mocks(), self.seal_mocks(),
): ):
self.assertEqual( self.assertEqual(
handler.translate_path("/favicon.ico"), handler.translate_path("/robots.txt"),
None, None,
) )
Generated
+1 -1
View File
@@ -212,7 +212,7 @@ wheels = [
[[package]] [[package]]
name = "stapler" name = "stapler"
version = "1.5.2" version = "1.6.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "requests" }, { name = "requests" },