mirror of
https://github.com/iFargle/headscale-webui.git
synced 2025-12-12 09:59:51 +01:00
Major part of #73 Unfortunately, it wasn't possible to split it to multiple smaller commits, since the changes touched the entire application substantially. Here is a short list of major changes: 1. Create a separate library (headscale-api), which is used as a convenient abstraction layer providing Pythonic interface with Pydantic. Headscale API is fully asynchronous library, benefitting from improved concurrency for backend requests thus increasing page load speed, e.g., on "Machines" page. 2. Create a common common, validated with flask-pydantic API passthrough layer from GUI to the backend. 3. Move authentication to a separate (auth.py), consolidating the functionality in a single place (with better place for expansion in the future). 4. Move configuration management to a separate module (config.py). Use Pydantic's BaseSettings for reading values from environment, with extensive validation and error reporting. 5. Reduce the number of health checks. - Now, most are performed during server initialization. If any test fails, the server is started in tainted mode, with only the error page exposed (thus reducing the surface of attack in invalid state). - Key checks are implicit in the requests to the backend and guarded by `@headscale.key_check_guard` decorator. - Key renewal is moved to server-side scheduler. 6. Introduce type hints to the level satisfactory for mypy static analysis. Also, enable some other linters in CI and add optional pre-commit hooks. 7. Properly handle some error states. Instead of returning success and handling different responses, if something fails, there is HTTP error code and standard response for it. 8. General formatting, small rewrites for clarity and more idiomatic Python constructs. Signed-off-by: Marek Pikuła <marek.pikula@embevity.com>
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
"""Headscale API abstraction."""
|
|
|
|
from functools import wraps
|
|
from typing import Awaitable, Callable, ParamSpec, TypeVar
|
|
|
|
from cryptography.fernet import Fernet
|
|
from flask import current_app, redirect, url_for
|
|
from flask.typing import ResponseReturnValue
|
|
from headscale_api.config import HeadscaleConfig as HeadscaleConfigBase
|
|
from headscale_api.headscale import Headscale, UnauthorizedError
|
|
from pydantic import ValidationError
|
|
|
|
from config import Config
|
|
|
|
T = TypeVar("T")
|
|
P = ParamSpec("P")
|
|
|
|
|
|
class HeadscaleApi(Headscale):
|
|
"""Headscale API abstraction."""
|
|
|
|
def __init__(self, config: Config, requests_timeout: float = 10):
|
|
"""Initialize the Headscale API abstraction.
|
|
|
|
Arguments:
|
|
config -- Headscale WebUI configuration.
|
|
|
|
Keyword Arguments:
|
|
requests_timeout -- timeout of API requests in seconds (default: {10})
|
|
"""
|
|
self._config = config
|
|
self._hs_config: HeadscaleConfigBase | None = None
|
|
self._api_key: str | None = None
|
|
self.logger = current_app.logger
|
|
super().__init__(
|
|
self.base_url,
|
|
self.api_key,
|
|
requests_timeout,
|
|
raise_exception_on_error=False,
|
|
logger=current_app.logger,
|
|
)
|
|
|
|
@property
|
|
def app_config(self) -> Config:
|
|
"""Get Headscale WebUI configuration."""
|
|
return self._config
|
|
|
|
@property
|
|
def hs_config(self) -> HeadscaleConfigBase | None:
|
|
"""Get Headscale configuration and cache on success.
|
|
|
|
Returns:
|
|
Headscale configuration if a valid configuration has been found.
|
|
"""
|
|
if self._hs_config is not None:
|
|
return self._hs_config
|
|
|
|
try:
|
|
return HeadscaleConfigBase.parse_file(self._config.hs_config_path)
|
|
except ValidationError as error:
|
|
self.logger.warning(
|
|
"Following errors happened when tried to parse Headscale config:"
|
|
)
|
|
for sub_error in str(error).splitlines():
|
|
self.logger.warning(" %s", sub_error)
|
|
return None
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
"""Get base URL of the Headscale server.
|
|
|
|
Tries to load it from Headscale config, otherwise falls back to WebUI config.
|
|
"""
|
|
if self.hs_config is None or self.hs_config.server_url is None:
|
|
self.logger.warning(
|
|
'Failed to find "server_url" in the Headscale config. Falling back to '
|
|
"the environment variable."
|
|
)
|
|
return self._config.hs_server
|
|
|
|
return self.hs_config.server_url
|
|
|
|
@property
|
|
def api_key(self) -> str | None:
|
|
"""Get API key from cache or from file."""
|
|
if self._api_key is not None:
|
|
return self._api_key
|
|
|
|
if not self._config.key_file.exists():
|
|
return None
|
|
|
|
with open(self._config.key_file, "rb") as key_file:
|
|
enc_api_key = key_file.read()
|
|
if enc_api_key == b"":
|
|
return None
|
|
|
|
self._api_key = Fernet(self._config.key).decrypt(enc_api_key).decode()
|
|
return self._api_key
|
|
|
|
@api_key.setter
|
|
def api_key(self, new_api_key: str):
|
|
"""Write the new API key to file and store in cache."""
|
|
with open(self._config.key_file, "wb") as key_file:
|
|
key_file.write(Fernet(self._config.key).encrypt(new_api_key.encode()))
|
|
|
|
# Save to local cache only after successful file write.
|
|
self._api_key = new_api_key
|
|
|
|
def key_check_guard(
|
|
self, func: Callable[P, T] | Callable[P, Awaitable[T]]
|
|
) -> Callable[P, T | ResponseReturnValue]:
|
|
"""Ensure the validity of a Headscale API key with decorator.
|
|
|
|
Also, it checks if the key needs renewal and if it is invalid redirects to the
|
|
settings page.
|
|
"""
|
|
|
|
@wraps(func)
|
|
def decorated(*args: P.args, **kwargs: P.kwargs) -> T | ResponseReturnValue:
|
|
try:
|
|
return current_app.ensure_sync(func)(*args, **kwargs) # type: ignore
|
|
except UnauthorizedError:
|
|
current_app.logger.warning(
|
|
"Detected unauthorized error from Headscale API. "
|
|
"Redirecting to settings."
|
|
)
|
|
return redirect(url_for("settings_page"))
|
|
|
|
return decorated
|