diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f9c510d..38fb2da 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -68,4 +68,5 @@ docker-run docker run - [x] detect root certificate change and update server - [x] detect tokens change and update token_manager - [x] proper doc - +- [x] X-Host-Only +- [ ] Fix certbot on base path with upgrade server diff --git a/README.md b/README.md index 72fd4c7..3241f58 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ options: PUT /{page}/ X-Token (your API token) X-Host (optional host as entrypoint) + X-Host-Only (optional host as entrypoint) (body with tar data) ``` @@ -121,14 +122,24 @@ tar -czC dist . | curl -X PUT \ --data-binary @- \ https://stapler-host/my-project/ -# make stapler server identifiers myproject.example.com as /my-project/ +# make stapler server identifiers myproject.example.com and /my-project/ tar -czC dist . | curl -X PUT \ --data-binary @- \ -H 'X-Token: ' \ -H 'X-Host: myproject.example.com' \ https://stapler-host/my-project/ + +# make stapler server identifiers myproject.example.com only +tar -czC dist . | curl -X PUT \ + --data-binary @- \ + -H 'X-Token: ' \ + -H 'X-Host-Only: myproject.example.com' \ + https://stapler-host/my-project/ ``` +> [!NOTE] +> Creating/updating comes with `X-Host` for enabling host-based resolving but you can also use `X-Host-Only` to do the same thing but disable listing as /path/. + ### Create/update page with redirect ```txt @@ -136,6 +147,7 @@ PUT /{page}/ X-Token (your API token) X-Redirect (redirection target) X-Host (optional host as entrypoint) + X-Host-Only (optional host as entrypoint) ``` ```bash @@ -160,6 +172,7 @@ PUT /{page}/ X-Token (your API token) X-Proxy (proxy target) X-Host (optional host as entrypoint) + X-Host-Only (optional host as entrypoint) ``` ```bash diff --git a/stapler/data_dir.py b/stapler/data_dir.py index 0df9826..6468047 100644 --- a/stapler/data_dir.py +++ b/stapler/data_dir.py @@ -56,6 +56,13 @@ class DataDir: file_path.chmod(chmod) self.logger.debug("Wrote %s", file_path) + def remove_file(self, path: str, file_name: str) -> None: + if self.exists(path): + file_path = self.root_path / path / file_name + if file_path.is_file(): + file_path.unlink() + self.logger.debug("Removed %s", file_path) + def get_file(self, path: str, file_name: str) -> str | None: if self.exists(path): file_path = self.root_path / path / file_name diff --git a/stapler/handlers.py b/stapler/handlers.py index 45aa341..f14e6db 100644 --- a/stapler/handlers.py +++ b/stapler/handlers.py @@ -17,7 +17,6 @@ from . import PKG_VERSION, STAPLER_ASCII, logs from .data_dir import DataDir if typing.TYPE_CHECKING: - from .cert_manager import CertManager from .page import Page from .params import Parameters from .registry import Registry @@ -223,6 +222,7 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): AUTHORIZED_PATHS: typing.ClassVar[list[str]] = ["/favicon.ico"] TOKEN_HEADER = "X-Token" # noqa: S105 HOST_HEADER = "X-Host" + HOST_ONLY_HEADER = "X-Host-Only" REDIRECT_HEADER = "X-Redirect" PROXY_HEADER = "X-Proxy" @@ -232,7 +232,6 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): *args: typing.Any, params: Parameters, registry: Registry, - cert_manager: CertManager, token_manager: TokenManager, **kwargs: dict[str, typing.Any], ) -> None: @@ -241,10 +240,10 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): self.data_dir: DataDir = DataDir(params.data_dir) self.max_size_bytes: int = params.max_size_bytes self.registry: Registry = registry - self.cert_manager: CertManager = cert_manager self.certbot_www: str = os.path.realpath(params.certbot_www) self.__token: str | None = None self.__target_host: str | None = None + self.__target_host_only: str | None = None self.__target_redirect: str | None = None self.__target_proxy: str | None = None super().__init__(*args, directory=params.data_dir, **kwargs, params=params) # ty:ignore[unknown-argument] @@ -260,14 +259,34 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): return len(self.token) > 0 @property - def target_host(self) -> str: + def request_host(self) -> str: if self.__target_host is None: self.__target_host = self._get_header(self.HOST_HEADER).lower() return self.__target_host + @property + def has_request_host(self) -> bool: + return len(self.request_host) > 0 + + @property + def request_host_only(self) -> str: + if self.__target_host_only is None: + self.__target_host_only = self._get_header(self.HOST_ONLY_HEADER).lower() + return self.__target_host_only + + @property + def has_request_host_only(self) -> bool: + return len(self.request_host_only) > 0 + + @property + def target_host(self) -> str: + if self.has_request_host: + return self.request_host + return self.request_host_only + @property def has_target_host(self) -> bool: - return len(self.target_host) > 0 + return self.has_request_host or self.has_request_host_only @property def target_redirect(self) -> str: @@ -310,31 +329,24 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): return None if (path := self.__check_update_request()) is None: return None - if self.has_target_host and not self.__valid_host(self.target_host): - return self.send_error( - http.HTTPStatus.BAD_REQUEST, "Invalid requested host" - ) + if not self.__check_put_headers(): + return None if ( self.has_target_host and (page := self.registry.get_from_host(self.target_host)) is not None and page.path != path ): return self.send_error(http.HTTPStatus.FORBIDDEN, "Host already taken") - if self.has_target_proxy and self.has_target_redirect: - return self.send_error( - http.HTTPStatus.BAD_REQUEST, - f"Cannot use {self.PROXY_HEADER} with {self.REDIRECT_HEADER}", - ) if self.has_target_redirect: self._update_redirect(path) elif self.has_target_proxy: self._update_proxy(path) else: self._update_extract(path) - if self.has_target_host and self.cert_manager.create_or_update( - self.target_host - ): + if self.has_request_host: self.registry.set_host(path, self.target_host) + if self.has_request_host_only: + self.registry.set_host_only(path, self.target_host) return None def do_POST(self) -> None: @@ -485,6 +497,24 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): return None return sub_path + def __check_put_headers(self) -> bool: + if self.has_request_host and self.has_request_host_only: + self.send_error( + http.HTTPStatus.BAD_REQUEST, + f"Cannot use {self.HOST_ONLY_HEADER} with {self.HOST_HEADER}", + ) + return False + if self.has_target_host and not self.__valid_host(self.target_host): + self.send_error(http.HTTPStatus.BAD_REQUEST, "Invalid requested host") + return False + if self.has_target_proxy and self.has_target_redirect: + self.send_error( + http.HTTPStatus.BAD_REQUEST, + f"Cannot use {self.PROXY_HEADER} with {self.REDIRECT_HEADER}", + ) + return False + return True + def __get_path(self, path: str, regex: re.Pattern) -> str | None: if (match := regex.match(path.lower())) is not None: return match.group(1) @@ -498,8 +528,12 @@ class RequestHandler(http.server.SimpleHTTPRequestHandler, BaseHandler): def __get_page(self, src_path: str) -> Page | None: if self.host == self.default_host: - if path := self.__get_path(src_path, self.GET_PATH_REGEX): - return self.registry.get_from_path(path) + if ( + (path := self.__get_path(src_path, self.GET_PATH_REGEX)) + and (page := self.registry.get_from_path(path)) is not None + and not page.host_only + ): + return page return None return self.registry.get_from_host(self.host) diff --git a/stapler/page.py b/stapler/page.py index 43a73ae..fb2e58b 100644 --- a/stapler/page.py +++ b/stapler/page.py @@ -6,6 +6,7 @@ class Page: path: str with_index: bool = False host: str | None = None + host_only: bool = False token_hash: str | None = None redirect: str | None = None proxy: str | None = None @@ -20,4 +21,6 @@ class Page: out += f" (proxy: {self.proxy})" elif not self.with_index: out += " (no index)" + if self.host_only: + out += " (host only)" return out diff --git a/stapler/registry.py b/stapler/registry.py index 855b621..1e46946 100644 --- a/stapler/registry.py +++ b/stapler/registry.py @@ -16,6 +16,7 @@ class Registry: ] HOST_FILE = ".host" + HOST_ONLY_FILE = ".host_only" TOKEN_FILE = ".token" # noqa: S105 REDIRECT_FILE = ".redirect" PROXY_FILE = ".proxy" @@ -34,22 +35,38 @@ class Registry: return [p.host for p in self.pages.values() if p.host is not None] def add(self, path: str) -> None: + host = self.data_dir.get_file(path, self.HOST_FILE) + host_only = self.data_dir.get_file(path, self.HOST_ONLY_FILE) self.pages[path] = Page( - path, - self.data_dir.has_index(path), - self.data_dir.get_file(path, self.HOST_FILE), - self.data_dir.get_file(path, self.TOKEN_FILE), - self.data_dir.get_file(path, self.REDIRECT_FILE), - self.data_dir.get_file(path, self.PROXY_FILE), + path=path, + with_index=self.data_dir.has_index(path), + host=host if host is not None else host_only, + host_only=host_only is not None, + token_hash=self.data_dir.get_file(path, self.TOKEN_FILE), + redirect=self.data_dir.get_file(path, self.REDIRECT_FILE), + proxy=self.data_dir.get_file(path, self.PROXY_FILE), ) self.logger.info("Updated %s", self.pages[path]) def set_host(self, path: str, host: str) -> None: - if path in self.pages and self.pages[path].host != host: + if path in self.pages and ( + self.pages[path].host != host or self.pages[path].host_only + ): self.data_dir.set_file(path, self.HOST_FILE, host) + self.data_dir.remove_file(path, self.HOST_ONLY_FILE) self.pages[path].host = host self.logger.debug("Updated %s", self.pages[path]) + def set_host_only(self, path: str, host: str) -> None: + if path in self.pages and ( + self.pages[path].host != host or not self.pages[path].host_only + ): + self.data_dir.set_file(path, self.HOST_ONLY_FILE, host) + self.data_dir.remove_file(path, self.HOST_FILE) + self.pages[path].host = host + self.pages[path].host_only = True + self.logger.debug("Updated %s", self.pages[path]) + def set_token_hash(self, path: str, token_hash: str) -> None: if path in self.pages and self.pages[path].token_hash != token_hash: self.data_dir.set_file(path, self.TOKEN_FILE, token_hash, 0o600) diff --git a/stapler/server.py b/stapler/server.py index 29f8e00..08b8d47 100644 --- a/stapler/server.py +++ b/stapler/server.py @@ -61,7 +61,6 @@ class StaplerServer: *args, params=self.params, registry=self.registry, - cert_manager=self.cert_manager, token_manager=self.token_manager, ) diff --git a/tests/test_data_dir.py b/tests/test_data_dir.py index dfc42b1..ef2de33 100644 --- a/tests/test_data_dir.py +++ b/tests/test_data_dir.py @@ -82,6 +82,21 @@ class TestDataDir(BaseTestCase): assert not (self.tmp_path / "test_1").exists() assert not (self.tmp_path / "test_1" / ".value").exists() + def test_remove_file_do_nothing(self) -> None: + self.__create_path("test_1") + self.data_dir.remove_file("test_1", ".value") + assert not (self.tmp_path / "test_1" / ".value").exists() + + def test_remove_file_ok(self) -> None: + self.__create_path("test_1", {".value": "test_value\nother_line"}) + self.data_dir.remove_file("test_1", ".value") + assert not (self.tmp_path / "test_1" / ".value").exists() + + def test_remove_file_invalid_path(self) -> None: + self.data_dir.remove_file("test_1", ".value") + assert not (self.tmp_path / "test_1").exists() + assert not (self.tmp_path / "test_1" / ".value").exists() + def test_remove(self) -> None: self.__create_path("test_1") self.data_dir.remove("test_1") diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 371813d..e74d6db 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -122,7 +122,6 @@ class TestRequestHandler(BaseHandlerTestCase): def setUp(self) -> None: self.get_tmp_dir() self.registry = self.new_mock() - self.cert_manager = self.new_mock() self.token_manager = self.new_mock() self.certbot_www = self.tmp_path / "certbot_www" self.data_dir = self.new_mock() @@ -146,7 +145,6 @@ class TestRequestHandler(BaseHandlerTestCase): data_dir=self.get_tmp_dir(), certbot_www=str(self.certbot_www) ), registry=self.registry, - cert_manager=self.cert_manager, token_manager=self.token_manager, ) handler.address_string = lambda: "127.0.0.1" # ty:ignore[invalid-assignment] @@ -285,6 +283,44 @@ class TestRequestHandler(BaseHandlerTestCase): ): handler.do_PUT() + def test_do_put_invalid_host_only(self) -> None: + handler = self._get_handler( + "/path", {"X-Token": "secret", "X-Host-Only": "invalid_host"} + ) + with ( + self.mock_call(self.token_manager.is_valid, ["secret"], True), # noqa: FBT003 + self.mock_call( + self.token_manager.is_valid_for_path, + ["secret", "path"], + True, # noqa: FBT003 + ), + self.expects_error( + handler, http.HTTPStatus.BAD_REQUEST, "Invalid requested host" + ), + self.seal_mocks(), + ): + handler.do_PUT() + + def test_do_put_invalid_host_and_host_only(self) -> None: + handler = self._get_handler( + "/path", {"X-Token": "secret", "X-Host": "host", "X-Host-Only": "host"} + ) + with ( + self.mock_call(self.token_manager.is_valid, ["secret"], True), # noqa: FBT003 + self.mock_call( + self.token_manager.is_valid_for_path, + ["secret", "path"], + True, # noqa: FBT003 + ), + self.expects_error( + handler, + http.HTTPStatus.BAD_REQUEST, + "Cannot use X-Host-Only with X-Host", + ), + self.seal_mocks(), + ): + handler.do_PUT() + def test_do_put_invalid_host_for_path(self) -> None: handler = self._get_handler( "/path", {"X-Token": "secret", "X-Host": "example.com"} @@ -404,31 +440,6 @@ class TestRequestHandler(BaseHandlerTestCase): ): handler.do_PUT() - def test_do_put_extract_with_host_fail_init(self) -> None: - handler = self._get_handler( - "/path", - {"X-Token": "secret", "Content-Length": "1", "X-Host": "example.com"}, - ) - handler.rfile.write(b"\0") - with ( - self.mock_call(self.token_manager.is_valid, ["secret"], True), # noqa: FBT003 - self.mock_call( - self.token_manager.is_valid_for_path, - ["secret", "path"], - True, # noqa: FBT003 - ), - self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), - self.mock_call_unchecked(self.data_dir.extract_tar_bytes), - self.mock_call(self.registry.add, ["path"]), - self.mock_call(self.token_manager.set_token, ["path", "secret"]), - self.mock_call(self.cert_manager.create_or_update, ["example.com"], False), # noqa: FBT003 - self.expects_status_only( - handler, http.HTTPStatus.CREATED, "Resource /path/ updated" - ), - self.seal_mocks(), - ): - handler.do_PUT() - def test_do_put_extract_with_host(self) -> None: handler = self._get_handler( "/path", @@ -446,7 +457,6 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call_unchecked(self.data_dir.extract_tar_bytes), self.mock_call(self.registry.add, ["path"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), - self.mock_call(self.cert_manager.create_or_update, ["example.com"], True), # noqa: FBT003 self.mock_call(self.registry.set_host, ["path", "example.com"]), self.expects_status_only( handler, http.HTTPStatus.CREATED, "Resource /path/ updated" @@ -523,7 +533,6 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), - self.mock_call(self.cert_manager.create_or_update, ["example.com"], True), # noqa: FBT003 self.mock_call(self.registry.set_host, ["path", "example.com"]), self.expects_status_only( handler, http.HTTPStatus.CREATED, "Resource /path/ updated" @@ -532,6 +541,33 @@ class TestRequestHandler(BaseHandlerTestCase): ): handler.do_PUT() + def test_do_put_redirect_with_host_only(self) -> None: + handler = self._get_handler( + "/path", + { + "X-Token": "secret", + "X-Redirect": "https://example.com", + "X-Host-Only": "example.com", + }, + ) + with ( + self.mock_call(self.token_manager.is_valid, ["secret"], True), # noqa: FBT003 + self.mock_call( + self.token_manager.is_valid_for_path, + ["secret", "path"], + True, # noqa: FBT003 + ), + self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), + self.mock_call(self.registry.set_redirect, ["path", "https://example.com"]), + self.mock_call(self.token_manager.set_token, ["path", "secret"]), + self.mock_call(self.registry.set_host_only, ["path", "example.com"]), + self.expects_status_only( + handler, http.HTTPStatus.CREATED, "Resource /path/ updated" + ), + self.seal_mocks(), + ): + handler.do_PUT() + def test_do_put_proxy_with_content(self) -> None: handler = self._get_handler( "/path", @@ -600,7 +636,6 @@ class TestRequestHandler(BaseHandlerTestCase): self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), self.mock_call(self.registry.set_proxy, ["path", "https://example.com"]), self.mock_call(self.token_manager.set_token, ["path", "secret"]), - self.mock_call(self.cert_manager.create_or_update, ["example.com"], True), # noqa: FBT003 self.mock_call(self.registry.set_host, ["path", "example.com"]), self.expects_status_only( handler, http.HTTPStatus.CREATED, "Resource /path/ updated" @@ -626,7 +661,6 @@ class TestRequestHandler(BaseHandlerTestCase): ["secret", "path"], True, # noqa: FBT003 ), - self.mock_call(self.registry.get_from_host, ["example.com"], Page("path")), self.expects_status_only( handler, http.HTTPStatus.BAD_REQUEST, @@ -1106,6 +1140,20 @@ class TestRequestHandler(BaseHandlerTestCase): None, ) + def test_translate_path_default_host_only(self) -> None: + handler = self._get_handler() + with ( + self.mock_call( + self.registry.get_from_path, ["path"], Page("path", host_only=True) + ), + self.patch("http.server.SimpleHTTPRequestHandler.translate_path", count=0), + self.seal_mocks(), + ): + self.assertEqual( + handler.translate_path("/path/index.html"), + "", + ) + def test_translate_path_default_host_not_found(self) -> None: handler = self._get_handler() with ( diff --git a/tests/test_page.py b/tests/test_page.py index 3964b05..dd1f3ef 100644 --- a/tests/test_page.py +++ b/tests/test_page.py @@ -27,3 +27,9 @@ class TestPage(BaseTestCase): str(Page("test_1", proxy="https://example.com")), "/test_1/ (proxy: https://example.com)", ) + + def test_repr_with_host_only(self) -> None: + self.assertEqual( + str(Page("test_1", with_index=True, host_only=True)), + "/test_1/ (host only)", + ) diff --git a/tests/test_registry.py b/tests/test_registry.py index 79cd4c3..93673ad 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -28,20 +28,24 @@ class TestRegistry(BaseTestCase): self.data_dir.get_file, [ ["test_1", Registry.HOST_FILE], + ["test_1", Registry.HOST_ONLY_FILE], ["test_1", Registry.TOKEN_FILE], ["test_1", Registry.REDIRECT_FILE], ["test_1", Registry.PROXY_FILE], ["test_2", Registry.HOST_FILE], + ["test_2", Registry.HOST_ONLY_FILE], ["test_2", Registry.TOKEN_FILE], ["test_2", Registry.REDIRECT_FILE], ["test_2", Registry.PROXY_FILE], ], [ + None, "test_1_host", "test_1_token", None, None, None, + None, "test_2_token", "test_2_redirect", None, @@ -58,6 +62,7 @@ class TestRegistry(BaseTestCase): "test_1", True, # noqa: FBT003 "test_1_host", + True, # noqa: FBT003 "test_1_token", None, ), @@ -68,6 +73,7 @@ class TestRegistry(BaseTestCase): "test_2", False, # noqa: FBT003 None, + False, # noqa: FBT003 "test_2_token", "test_2_redirect", ), @@ -98,10 +104,30 @@ class TestRegistry(BaseTestCase): self.mock_call( self.data_dir.set_file, ["test_1", Registry.HOST_FILE, "new_value"] ), + self.mock_call( + self.data_dir.remove_file, ["test_1", Registry.HOST_ONLY_FILE] + ), self.seal_mocks(), ): self.registry.set_host("test_1", "new_value") self.assertEqual(self.registry.pages["test_1"].host, "new_value") + assert not self.registry.pages["test_1"].host_only + + def test_set_host_only(self) -> None: + self.registry.pages["test_1"] = Page( + "test_1", + host="test_1_host", + ) + with ( + self.mock_call( + self.data_dir.set_file, ["test_1", Registry.HOST_ONLY_FILE, "new_value"] + ), + self.mock_call(self.data_dir.remove_file, ["test_1", Registry.HOST_FILE]), + self.seal_mocks(), + ): + self.registry.set_host_only("test_1", "new_value") + self.assertEqual(self.registry.pages["test_1"].host, "new_value") + assert self.registry.pages["test_1"].host_only def test_set_token_hash(self) -> None: self.registry.pages["test_1"] = Page(