Complete the major refactor

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>
This commit is contained in:
Marek Pikuła
2023-04-21 05:26:11 +00:00
parent 20a4c9d3f6
commit fe7a3667d4
16 changed files with 3327 additions and 2691 deletions

241
auth.py Normal file
View File

@@ -0,0 +1,241 @@
"""Headscale WebUI authentication abstraction."""
import secrets
from functools import wraps
from typing import Awaitable, Callable, Literal, ParamSpec, TypeVar
import requests
from flask import current_app
from flask.typing import ResponseReturnValue
from flask_basicauth import BasicAuth # type: ignore
from flask_oidc import OpenIDConnect # type: ignore
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, Field
from config import BasicAuthConfig, Config, OidcAuthConfig
class OidcSecretsModel(BaseModel):
"""OIDC secrets model used by the flask_oidc module."""
class OidcWebModel(BaseModel):
"""OIDC secrets web model."""
issuer: AnyHttpUrl
auth_uri: AnyHttpUrl
client_id: str
client_secret: str = Field(hidden=True)
redirect_uris: list[AnyUrl]
userinfo_uri: AnyHttpUrl | None
token_uri: AnyHttpUrl
web: OidcWebModel
class OpenIdProviderMetadata(BaseModel):
"""OIDC Provider Metadata model.
From https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
TODO: Add default factories for some fields and maybe descriptions.
"""
class Config:
"""BaseModel configuration."""
extra = "allow"
"""Used for logout_redirect_uri."""
issuer: AnyHttpUrl
authorization_endpoint: AnyHttpUrl
token_endpoint: AnyHttpUrl
userinfo_endpoint: AnyHttpUrl | None
jwks_uri: AnyHttpUrl
registration_endpoint: AnyHttpUrl | None
scopes_supported: list[str]
response_types_supported: list[
Literal[
"code",
"id_token",
"id_token token",
"code id_token",
"code token",
"code id_token token",
]
]
response_modes_supported: list[Literal["query", "fragment"]] | None
grant_types_supported: list[str] | None
acr_values_supported: list[str] | None
subject_types_supported: list[str]
id_token_signing_alg_values_supported: list[str]
id_token_encryption_alg_values_supported: list[str] | None
id_token_encryption_enc_values_supported: list[str] | None
userinfo_signing_alg_values_supported: list[str | None] | None
userinfo_encryption_alg_values_supported: list[str] | None
userinfo_encryption_enc_values_supported: list[str] | None
request_object_signing_alg_values_supported: list[str] | None
request_object_encryption_alg_values_supported: list[str] | None
request_object_encryption_enc_values_supported: list[str] | None
token_endpoint_auth_methods_supported: list[str] | None
token_endpoint_auth_signing_alg_values_supported: list[str] | None
display_values_supported: list[Literal["page", "popup", "touch", "wap"]] | None
claim_types_supported: list[Literal["normal", "aggregated", "distributed"]] | None
claims_supported: list[str] | None
service_documentation: AnyUrl | None
claims_locales_supported: list[str] | None
ui_locales_supported: list[str] | None
claims_parameter_supported: bool = Field(False)
request_parameter_supported: bool = Field(False)
request_uri_parameter_supported: bool = Field(True)
require_request_uri_registration: bool = Field(False)
op_policy_uri: AnyUrl | None
op_tos_uri: AnyUrl | None
T = TypeVar("T")
P = ParamSpec("P")
class AuthManager:
"""Authentication manager."""
def __init__(self, config: Config, request_timeout: float = 10) -> None:
"""Initialize the authentication manager.
Arguments:
config -- main application configuration.
Keyword Arguments:
request_timeout -- timeout for OIDC request (default: {10})
"""
self._gui_url = config.domain_name + config.base_path
self._auth_type = config.auth_type
self._auth_config = config.auth_type.config
self._logout_url: str | None = None
self._request_timeout = request_timeout
match self._auth_config:
case BasicAuthConfig():
current_app.logger.info(
"Loading basic auth libraries and configuring app..."
)
current_app.config["BASIC_AUTH_USERNAME"] = self._auth_config.username
current_app.config["BASIC_AUTH_PASSWORD"] = self._auth_config.password
current_app.config["BASIC_AUTH_FORCE"] = True
# TODO: Change for flask-httpauth flask_basicauth is not maintained.
self._auth_handler = BasicAuth(current_app)
case OidcAuthConfig():
current_app.logger.info("Loading OIDC libraries and configuring app...")
oidc_info = OpenIdProviderMetadata.parse_obj(
requests.get(
self._auth_config.auth_url, timeout=request_timeout
).json()
)
current_app.logger.debug(
"JSON dump for OIDC_INFO: %s", oidc_info.json()
)
client_secrets = OidcSecretsModel(
web=OidcSecretsModel.OidcWebModel(
issuer=oidc_info.issuer,
auth_uri=oidc_info.authorization_endpoint,
client_id=self._auth_config.client_id,
client_secret=self._auth_config.secret,
redirect_uris=[
AnyUrl(
f"{config.domain_name}{config.base_path}/oidc_callback",
scheme="",
)
],
userinfo_uri=oidc_info.userinfo_endpoint,
token_uri=oidc_info.token_endpoint,
)
)
# Make the best effort to create the data directory.
try:
config.app_data_dir.mkdir(parents=True, exist_ok=True)
except PermissionError:
current_app.logger.warning(
"Tried and failed to create data directory %s.",
config.app_data_dir,
)
oidc_secrets_path = config.app_data_dir / "secrets.json"
with open(oidc_secrets_path, "w+", encoding="utf-8") as secrets_file:
secrets_file.write(client_secrets.json())
current_app.config.update( # type: ignore
{
"SECRET_KEY": secrets.token_urlsafe(32),
"TESTING": config.debug_mode,
"DEBUG": config.debug_mode,
"OIDC_CLIENT_SECRETS": oidc_secrets_path,
"OIDC_ID_TOKEN_COOKIE_SECURE": True,
"OIDC_REQUIRE_VERIFIED_EMAIL": False,
"OIDC_USER_INFO_ENABLED": True,
"OIDC_OPENID_REALM": "Headscale-WebUI",
"OIDC_SCOPES": ["openid", "profile", "email"],
"OIDC_INTROSPECTION_AUTH_METHOD": "client_secret_post",
}
)
self._logout_url = getattr(oidc_info, "end_session_endpoint", None)
self._auth_handler = OpenIDConnect(current_app)
def require_login(
self,
func: Callable[P, ResponseReturnValue]
| Callable[P, Awaitable[ResponseReturnValue]],
) -> Callable[P, ResponseReturnValue]:
"""Guard decorator used for restricting access to the Flask page.
Uses OIDC or Basic auth depending on configuration.
"""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
sync_func = current_app.ensure_sync(func) # type: ignore
sync_func.__name__ = f"{func.__name__}"
# OIDC
# TODO: Add user group restrictions.
if isinstance(self._auth_handler, OpenIDConnect):
return self._auth_handler.require_login(sync_func)( # type: ignore
*args, **kwargs
)
# Basic auth
return self._auth_handler.required(sync_func)( # type: ignore
*args, **kwargs
)
return wrapper
def logout(self) -> str | None:
"""Execute logout with the auth provider."""
# Logout is only applicable for OIDC.
if isinstance(self._auth_handler, OpenIDConnect):
self._auth_handler.logout()
if isinstance(self._auth_config, OidcAuthConfig):
if self._logout_url is not None:
logout_url = self._logout_url
if self._auth_config.logout_redirect_uri is not None:
logout_url += (
"?post_logout_redirect_uri="
+ self._auth_config.logout_redirect_uri
)
return logout_url
return None
@property
def oidc_handler(self) -> OpenIDConnect | None:
"""Get the OIDC handler if exists."""
if isinstance(self._auth_handler, OpenIDConnect):
return self._auth_handler
return None

527
config.py Normal file
View File

@@ -0,0 +1,527 @@
"""Headscale WebUI configuration."""
import importlib.metadata
import itertools
import os
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from logging import getLevelNamesMapping
from pathlib import Path
from typing import Any, Type
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from aiohttp import ClientConnectionError
from flask import current_app
from pydantic import validator # type: ignore
from pydantic import (
AnyUrl,
BaseModel,
BaseSettings,
ConstrainedStr,
Field,
ValidationError,
)
import helper
class OidcAuthConfig(BaseSettings):
"""OpenID Connect authentication configuration.
Used only if "AUTH_TYPE" environment variable is set to "oidc".
"""
auth_url: str = Field(
...,
env="OIDC_AUTH_URL",
description=(
"URL to OIDC auth endpoint. Example: "
'"https://example.com/.well-known/openid-configuration"'
),
)
client_id: str = Field(
env="OIDC_CLIENT_ID",
description="OIDC client ID.",
)
secret: str = Field(
env="OIDC_CLIENT_SECRET",
description="OIDC client secret.",
)
logout_redirect_uri: str | None = Field(
None,
env="OIDC_LOGOUT_REDIRECT_URI",
description="Optional OIDC redirect URL to follow after logout.",
)
class BasicAuthConfig(BaseSettings):
"""Basic auth authentication configuration.
Used only if "AUTH_TYPE" environment variable is set to "basic".
"""
username: str = Field(env="BASIC_AUTH_USER", description="Username for basic auth.")
password: str = Field(env="BASIC_AUTH_PASS", description="Password for basic auth.")
class AuthType(StrEnum):
"""Authentication type."""
BASIC = "basic"
OIDC = "oidc"
@property
def config(self):
"""Get configuration depending on enum value."""
match self:
case self.BASIC:
return BasicAuthConfig() # type: ignore
case self.OIDC:
return OidcAuthConfig() # type: ignore
class _LowerConstr(ConstrainedStr):
"""String with lowercase transformation."""
to_lower = True
@dataclass
class InitCheckErrorModel:
"""Initialization check error model."""
title: str
details: str
def print_to_logger(self):
"""Print the error information to logger."""
current_app.logger.critical(self.title)
def format_message(self) -> str:
"""Format message for the error page."""
return helper.format_message(
helper.MessageErrorType.ERROR, self.title, f"<p>{self.details}</p>"
)
@dataclass
class InitCheckError(RuntimeError):
"""Initialization check error."""
errors: list[InitCheckErrorModel] | InitCheckErrorModel | None = None
def append_error(self, error: InitCheckErrorModel):
"""Append error to the errors collection."""
match self.errors:
case InitCheckErrorModel():
self.errors = [self.errors, error]
case list():
self.errors.append(error)
case _:
self.errors = error
def __iter__(self): # noqa
match self.errors:
case InitCheckErrorModel():
yield self.errors
case list():
for error in self.errors:
yield error
case _:
return
@classmethod
def from_validation_error(cls, error: ValidationError):
"""Create an InitCheckError from Pydantic's ValidationError."""
current_app.logger.critical(
"Following environment variables are required but are not declared or have "
"an invalid value:"
)
new_error = cls()
for sub_pydantic_error in error.errors():
pydantic_name = sub_pydantic_error["loc"][0]
assert isinstance(
pydantic_name, str
), "Configuration class malformed. Raise issue on GitHub."
model: Type[BaseModel] = error.model # type: ignore
field = model.__fields__[pydantic_name]
assert (
"env" in field.field_info.extra
), "Environment variable name not set. Raise issue on GitHub."
current_app.logger.critical(
" %s with type %s: %s",
field.field_info.extra["env"],
field.type_.__name__,
sub_pydantic_error["type"],
)
new_error.append_error(
InitCheckErrorModel(
f"Environment error for {field.field_info.extra['env']}",
f"Required variable {field.field_info.extra['env']} with type "
f'"{field.type_.__name__}" validation error '
f"({sub_pydantic_error['type']}): {sub_pydantic_error['msg']}. "
f"Variable description: {field.field_info.description}",
)
)
return new_error
@classmethod
def from_client_connection_error(cls, error: ClientConnectionError):
"""Create an InitCheckError from aiohttp's ClientConnectionError."""
return InitCheckError(
InitCheckErrorModel(
"Headscale server API is unreachable.",
"Your headscale server is either unreachable or not properly "
"configured. Please ensure your configuration is correct. Error"
f"details: {error}",
)
)
@classmethod
def from_exception(cls, error: Exception, print_to_logger: bool = True):
"""Create an InitCheckError from any error.
Some special cases are handled separately.
"""
if isinstance(error, InitCheckError):
new_error = error
elif isinstance(error, ValidationError):
new_error = cls.from_validation_error(error)
elif isinstance(error, ClientConnectionError):
new_error = cls.from_client_connection_error(error)
else:
new_error = cls(
InitCheckErrorModel(
f"Unexpected error occurred: {error.__class__.__name__}. Raise an "
"issue on GitHub.",
str(error),
)
)
if print_to_logger:
for sub_error in new_error:
sub_error.print_to_logger()
return new_error
def _get_version_from_package():
"""Get package version from metadata if not given from environment."""
return importlib.metadata.version("headscale-webui")
# Functions to get git-related information in development scenario, where no relevant
# environment variables are set. If not in git repository fall back to unknown values.
# GitPython is added as dev dependency, thus we need to have fallback in case of
# production environment.
try:
from git.exc import GitError
from git.repo import Repo
def _get_default_git_branch() -> str:
try:
return Repo(search_parent_directories=True).head.ref.name
except GitError as error:
return f"Error getting branch name: {error}"
def _get_default_git_commit() -> str:
try:
return Repo(search_parent_directories=True).head.ref.object.hexsha
except GitError as error:
return f"Error getting commit ID: {error}"
def _get_default_git_repo_url_gitpython() -> str | None:
try:
return (
Repo(search_parent_directories=True)
.remotes[0]
.url.replace("git@github.com:", "https://github.com/")
.removesuffix(".git")
)
except (GitError, IndexError):
return None
except ImportError:
def _get_default_git_branch() -> str:
return "UNKNOWN"
def _get_default_git_commit() -> str:
return "UNKNOWN"
def _get_default_git_repo_url_gitpython() -> str | None:
return None
def _get_default_git_repo_url():
gitpython = _get_default_git_repo_url_gitpython()
return (
"https://github.com/iFargle/headscale-webui" if gitpython is None else gitpython
)
class Config(BaseSettings):
"""Headscale WebUI configuration.
`env` arg means what is the environment variable called.
"""
color: _LowerConstr = Field(
"red",
env="COLOR",
description=(
"Preferred color scheme. See the MaterializeCSS docs "
"(https://materializecss.github.io/materialize/color.html#palette) for "
'examples. Only set the "base" color, e.g., instead of `blue-gray '
"darken-1` use `blue-gray`."
),
)
auth_type: AuthType = Field(
AuthType.BASIC,
env="AUTH_TYPE",
description="Authentication type.",
)
log_level_name: str = Field(
"INFO",
env="LOG_LEVEL",
description=(
'Logger level. If "DEBUG", Flask debug mode is activated, so don\'t use it '
"in production."
),
)
debug_mode: bool = Field(
False,
env="DEBUG_MODE",
description="Enable Flask debug mode.",
)
# TODO: Use user's locale to present datetime, not from server-side constant.
timezone: ZoneInfo = Field(
"UTC",
env="TZ",
description='Default time zone in IANA format. Example: "Asia/Tokyo".',
)
key: str = Field(
env="KEY",
description=(
"Encryption key. Set this to a random value generated from "
"`openssl rand -base64 32`."
),
)
app_version: str = Field(
default_factory=_get_version_from_package,
env="APP_VERSION",
description="Application version. Should be set by Docker.",
)
build_date: datetime = Field(
default_factory=datetime.now,
env="BUILD_DATE",
description="Application build date. Should be set by Docker.",
)
git_branch: str = Field(
default_factory=_get_default_git_branch,
env="GIT_BRANCH",
description="Application git branch. Should be set by Docker.",
)
git_commit: str = Field(
default_factory=_get_default_git_commit,
env="GIT_COMMIT",
description="Application git commit. Should be set by Docker.",
)
git_repo_url: AnyUrl = Field(
default_factory=_get_default_git_repo_url,
env="GIT_REPO_URL",
description=(
"Application git repository URL. "
"Set automatically either to local or default repository."
),
)
# TODO: Autogenerate in headscale_api.
hs_version: str = Field(
"UNKNOWN",
env="HS_VERSION",
description=(
"Version of Headscale this is compatible with. Should be set by Docker."
),
)
hs_server: AnyUrl = Field(
"http://localhost:5000",
env="HS_SERVER",
description="The URL of your Headscale control server.",
)
hs_config_path: Path = Field(
None,
env="HS_CONFIG_PATH",
description=(
"Path to the Headscale configuration. Default paths are tried if not set."
),
)
domain_name: AnyUrl = Field(
"http://localhost:5000",
env="DOMAIN_NAME",
description="Base domain name of the Headscale WebUI.",
)
base_path: str = Field(
"",
env="SCRIPT_NAME",
description=(
'The "Base Path" for hosting. For example, if you want to host on '
"http://example.com/admin, set this to `/admin`, otherwise remove this "
"variable entirely."
),
)
app_data_dir: Path = Field(
Path("/data"),
env="APP_DATA_DIR",
description="Application data path.",
)
@validator("log_level_name")
@classmethod
def validate_log_level_name(cls, value: Any):
"""Validate log_level_name field.
Check if matches allowed log level from logging Python module.
"""
assert isinstance(value, str)
value = value.upper()
allowed_levels = getLevelNamesMapping()
if value not in allowed_levels:
raise ValueError(
f'Unkown log level "{value}". Select from: '
+ ", ".join(allowed_levels.keys())
)
return value
@validator("timezone", pre=True)
@classmethod
def validate_timezone(cls, value: Any):
"""Validate and parse timezone information."""
try:
return ZoneInfo(value)
except ZoneInfoNotFoundError as error:
raise ValueError(f"Timezone {value} is invalid: {error}") from error
@validator("hs_config_path", pre=True)
@classmethod
def validate_hs_config_path(cls, value: Any):
"""Validate Headscale configuration path.
If none is given, some default paths that Headscale itself is using for lookup
are searched.
"""
if value is None:
search_base = ["/etc/headscale", Path.home() / ".headscale"]
suffixes = ["yml", "yaml", "json"]
else:
assert isinstance(value, (str, Path))
search_base = [value]
suffixes = [""]
for base, suffix in itertools.product(search_base, suffixes):
cur_path = f"{base}/config.{suffix}"
if os.access(cur_path, os.R_OK):
return cur_path
raise InitCheckError(
InitCheckErrorModel(
"Headscale configuration read failed.",
"Please ensure your headscale configuration file resides in "
'/etc/headscale or in ~/.headscale and is named "config.yaml", '
'"config.yml" or "config.json".',
)
)
@validator("base_path")
@classmethod
def validate_base_path(cls, value: Any):
"""Validate base path."""
assert isinstance(value, str)
if value == "/":
return ""
return value
@validator("app_data_dir")
@classmethod
def validate_app_data_dir(cls, value: Path):
"""Validate application data format and basic filesystem access."""
err = InitCheckError()
if not os.access(value, os.R_OK):
err.append_error(
InitCheckErrorModel(
f"Data ({value}) folder not readable.",
f'"{value}" is not readable. Please ensure your permissions are '
"correct. Data should be readable by UID/GID 1000:1000.",
)
)
if not os.access(value, os.W_OK):
err.append_error(
InitCheckErrorModel(
f"Data ({value}) folder not writable.",
f'"{value}" is not writable. Please ensure your permissions are '
"correct. Data should be writable by UID/GID 1000:1000.",
)
)
if not os.access(value, os.X_OK):
err.append_error(
InitCheckErrorModel(
f"Data ({value}) folder not executable.",
f'"{value}" is not executable. Please ensure your permissions are '
"correct. Data should be executable by UID/GID 1000:1000.",
)
)
key_file = value / "key.txt"
if key_file.exists():
if not os.access(key_file, os.R_OK):
err.append_error(
InitCheckErrorModel(
f"Key file ({key_file}) not readable.",
f'"{key_file}" is not readable. Please ensure your permissions '
"are correct. It should be readable by UID/GID 1000:1000.",
)
)
if not os.access(key_file, os.W_OK):
err.append_error(
InitCheckErrorModel(
f"Key file ({key_file}) not writable.",
f'"{key_file}" is not writable. Please ensure your permissions '
"are correct. It should be writable by UID/GID 1000:1000.",
)
)
if err.errors is not None:
raise err
return value
@property
def log_level(self) -> int:
"""Get integer log level."""
return getLevelNamesMapping()[self.log_level_name]
@property
def color_nav(self):
"""Get navigation color."""
return f"{self.color} darken-1"
@property
def color_btn(self):
"""Get button color."""
return f"{self.color} darken-3"
@property
def key_file(self):
"""Get key file path."""
return self.app_data_dir / "key.txt"

View File

@@ -1,488 +1,129 @@
import json
import logging
import os
from datetime import date, timedelta
"""Headscale API abstraction."""
from functools import wraps
from typing import Awaitable, Callable, ParamSpec, TypeVar
import requests
import yaml
from cryptography.fernet import Fernet
from dateutil import parser
from flask import Flask
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
LOG_LEVEL = os.environ["LOG_LEVEL"].replace('"', "").upper()
# Initiate the Flask application and logging:
app = Flask(__name__, static_url_path="/static")
match LOG_LEVEL:
case "DEBUG":
app.logger.setLevel(logging.DEBUG)
case "INFO":
app.logger.setLevel(logging.INFO)
case "WARNING":
app.logger.setLevel(logging.WARNING)
case "ERROR":
app.logger.setLevel(logging.ERROR)
case "CRITICAL":
app.logger.setLevel(logging.CRITICAL)
from config import Config
T = TypeVar("T")
P = ParamSpec("P")
##################################################################
# Functions related to HEADSCALE and API KEYS
##################################################################
def get_url(inpage=False):
if not inpage:
return os.environ["HS_SERVER"]
config_file = ""
try:
config_file = open("/etc/headscale/config.yml", "r")
app.logger.info("Opening /etc/headscale/config.yml")
except:
config_file = open("/etc/headscale/config.yaml", "r")
app.logger.info("Opening /etc/headscale/config.yaml")
config_yaml = yaml.safe_load(config_file)
if "server_url" in config_yaml:
return str(config_yaml["server_url"])
app.logger.warning(
"Failed to find server_url in the config. Falling back to ENV variable"
)
return os.environ["HS_SERVER"]
class HeadscaleApi(Headscale):
"""Headscale API abstraction."""
def __init__(self, config: Config, requests_timeout: float = 10):
"""Initialize the Headscale API abstraction.
def set_api_key(api_key):
# User-set encryption key
encryption_key = os.environ["KEY"]
# Key file on the filesystem for persistent storage
key_file = open("/data/key.txt", "wb+")
# Preparing the Fernet class with the key
fernet = Fernet(encryption_key)
# Encrypting the key
encrypted_key = fernet.encrypt(api_key.encode())
# Return true if the file wrote correctly
return True if key_file.write(encrypted_key) else False
Arguments:
config -- Headscale WebUI configuration.
def get_api_key():
if not os.path.exists("/data/key.txt"):
return False
# User-set encryption key
encryption_key = os.environ["KEY"]
# Key file on the filesystem for persistent storage
key_file = open("/data/key.txt", "rb+")
# The encrypted key read from the file
enc_api_key = key_file.read()
if enc_api_key == b"":
return "NULL"
# Preparing the Fernet class with the key
fernet = Fernet(encryption_key)
# Decrypting the key
decrypted_key = fernet.decrypt(enc_api_key).decode()
return decrypted_key
def test_api_key(url, api_key):
response = requests.get(
str(url) + "/api/v1/apikey",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.status_code
# Expires an API key
def expire_key(url, api_key):
payload = {"prefix": str(api_key[0:10])}
json_payload = json.dumps(payload)
app.logger.debug(
"Sending the payload '" + str(json_payload) + "' to the headscale server"
)
response = requests.post(
str(url) + "/api/v1/apikey/expire",
data=json_payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.status_code
# Checks if the key needs to be renewed
# If it does, renews the key, then expires the old key
def renew_api_key(url, api_key):
# 0 = Key has been updated or key is not in need of an update
# 1 = Key has failed validity check or has failed to write the API key
# Check when the key expires and compare it to todays date:
key_info = get_api_key_info(url, api_key)
expiration_time = key_info["expiration"]
today_date = date.today()
expire = parser.parse(expiration_time)
expire_fmt = (
str(expire.year)
+ "-"
+ str(expire.month).zfill(2)
+ "-"
+ str(expire.day).zfill(2)
)
expire_date = date.fromisoformat(expire_fmt)
delta = expire_date - today_date
tmp = today_date + timedelta(days=90)
new_expiration_date = str(tmp) + "T00:00:00.000000Z"
# If the delta is less than 5 days, renew the key:
if delta < timedelta(days=5):
app.logger.warning("Key is about to expire. Delta is " + str(delta))
payload = {"expiration": str(new_expiration_date)}
json_payload = json.dumps(payload)
app.logger.debug(
"Sending the payload '" + str(json_payload) + "' to the headscale server"
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,
)
response = requests.post(
str(url) + "/api/v1/apikey",
data=json_payload,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
new_key = response.json()
app.logger.debug("JSON: " + json.dumps(new_key))
app.logger.debug("New Key is: " + new_key["apiKey"])
api_key_test = test_api_key(url, new_key["apiKey"])
app.logger.debug("Testing the key: " + str(api_key_test))
# Test if the new key works:
if api_key_test == 200:
app.logger.info("The new key is valid and we are writing it to the file")
if not set_api_key(new_key["apiKey"]):
app.logger.error("We failed writing the new key!")
return False # Key write failed
app.logger.info("Key validated and written. Moving to expire the key.")
expire_key(url, api_key)
return True # Key updated and validated
else:
app.logger.error("Testing the API key failed.")
return False # The API Key test failed
else:
return True # No work is required
@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.
# Gets information about the current API key
def get_api_key_info(url, api_key):
app.logger.info("Getting API key information")
response = requests.get(
str(url) + "/api/v1/apikey",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
json_response = response.json()
# Find the current key in the array:
key_prefix = str(api_key[0:10])
app.logger.info("Looking for valid API Key...")
for key in json_response["apiKeys"]:
if key_prefix == key["prefix"]:
app.logger.info("Key found.")
return key
app.logger.error("Could not find a valid key in Headscale. Need a new API key.")
return "Key not found"
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
##################################################################
# Functions related to MACHINES
##################################################################
@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
# register a new machine
def register_machine(url, api_key, machine_key, user):
app.logger.info("Registering machine %s to user %s", str(machine_key), str(user))
response = requests.post(
str(url)
+ "/api/v1/machine/register?user="
+ str(user)
+ "&key="
+ str(machine_key),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
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
# Sets the machines tags
def set_machine_tags(url, api_key, machine_id, tags_list):
app.logger.info("Setting machine_id %s tag %s", str(machine_id), str(tags_list))
response = requests.post(
str(url) + "/api/v1/machine/" + str(machine_id) + "/tags",
data=tags_list,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
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
# Moves machine_id to user "new_user"
def move_user(url, api_key, machine_id, new_user):
app.logger.info("Moving machine_id %s to user %s", str(machine_id), str(new_user))
response = requests.post(
str(url) + "/api/v1/machine/" + str(machine_id) + "/user?user=" + str(new_user),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
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()))
def update_route(url, api_key, route_id, current_state):
action = "disable" if current_state == "True" else "enable"
# Save to local cache only after successful file write.
self._api_key = new_api_key
app.logger.info("Updating Route %s: Action: %s", str(route_id), str(action))
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.
# Debug
app.logger.debug("URL: " + str(url))
app.logger.debug("Route ID: " + str(route_id))
app.logger.debug("Current State: " + str(current_state))
app.logger.debug("Action to take: " + str(action))
Also, it checks if the key needs renewal and if it is invalid redirects to the
settings page.
"""
response = requests.post(
str(url) + "/api/v1/routes/" + str(route_id) + "/" + str(action),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
@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"))
# Get all machines on the Headscale network
def get_machines(url, api_key):
app.logger.info("Getting machine information")
response = requests.get(
str(url) + "/api/v1/machine",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
# Get machine with "machine_id" on the Headscale network
def get_machine_info(url, api_key, machine_id):
app.logger.info("Getting information for machine ID %s", str(machine_id))
response = requests.get(
str(url) + "/api/v1/machine/" + str(machine_id),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
# Delete a machine from Headscale
def delete_machine(url, api_key, machine_id):
app.logger.info("Deleting machine %s", str(machine_id))
response = requests.delete(
str(url) + "/api/v1/machine/" + str(machine_id),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("Machine deleted.")
else:
app.logger.error("Deleting machine failed! %s", str(response.json()))
return {"status": status, "body": response.json()}
# Rename "machine_id" with name "new_name"
def rename_machine(url, api_key, machine_id, new_name):
app.logger.info("Renaming machine %s", str(machine_id))
response = requests.post(
str(url) + "/api/v1/machine/" + str(machine_id) + "/rename/" + str(new_name),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("Machine renamed")
else:
app.logger.error("Machine rename failed! %s", str(response.json()))
return {"status": status, "body": response.json()}
# Gets routes for the passed machine_id
def get_machine_routes(url, api_key, machine_id):
app.logger.info("Getting routes for machine %s", str(machine_id))
response = requests.get(
str(url) + "/api/v1/machine/" + str(machine_id) + "/routes",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
if response.status_code == 200:
app.logger.info("Routes obtained")
else:
app.logger.error("Failed to get routes: %s", str(response.json()))
return response.json()
# Gets routes for the entire tailnet
def get_routes(url, api_key):
app.logger.info("Getting routes")
response = requests.get(
str(url) + "/api/v1/routes",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
##################################################################
# Functions related to USERS
##################################################################
# Get all users in use
def get_users(url, api_key):
app.logger.info("Getting Users")
response = requests.get(
str(url) + "/api/v1/user",
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
# Rename "old_name" with name "new_name"
def rename_user(url, api_key, old_name, new_name):
app.logger.info("Renaming user %s to %s.", str(old_name), str(new_name))
response = requests.post(
str(url) + "/api/v1/user/" + str(old_name) + "/rename/" + str(new_name),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("User renamed.")
else:
app.logger.error("Renaming User failed!")
return {"status": status, "body": response.json()}
# Delete a user from Headscale
def delete_user(url, api_key, user_name):
app.logger.info("Deleting a User: %s", str(user_name))
response = requests.delete(
str(url) + "/api/v1/user/" + str(user_name),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("User deleted.")
else:
app.logger.error("Deleting User failed!")
return {"status": status, "body": response.json()}
# Add a user from Headscale
def add_user(url, api_key, data):
app.logger.info("Adding user: %s", str(data))
response = requests.post(
str(url) + "/api/v1/user",
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("User added.")
else:
app.logger.error("Adding User failed!")
return {"status": status, "body": response.json()}
##################################################################
# Functions related to PREAUTH KEYS in USERS
##################################################################
# Get all PreAuth keys associated with a user "user_name"
def get_preauth_keys(url, api_key, user_name):
app.logger.info("Getting PreAuth Keys in User %s", str(user_name))
response = requests.get(
str(url) + "/api/v1/preauthkey?user=" + str(user_name),
headers={
"Accept": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
return response.json()
# Add a preauth key to the user "user_name" given the booleans "ephemeral"
# and "reusable" with the expiration date "date" contained in the JSON payload "data"
def add_preauth_key(url, api_key, data):
app.logger.info("Adding PreAuth Key: %s", str(data))
response = requests.post(
str(url) + "/api/v1/preauthkey",
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
if response.status_code == 200:
app.logger.info("PreAuth Key added.")
else:
app.logger.error("Adding PreAuth Key failed!")
return {"status": status, "body": response.json()}
# Expire a pre-auth key. data is {"user": "string", "key": "string"}
def expire_preauth_key(url, api_key, data):
app.logger.info("Expiring PreAuth Key...")
response = requests.post(
str(url) + "/api/v1/preauthkey/expire",
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": "Bearer " + str(api_key),
},
)
status = "True" if response.status_code == 200 else "False"
app.logger.debug("expire_preauth_key - Return: " + str(response.json()))
app.logger.debug("expire_preauth_key - Status: " + str(status))
return {"status": status, "body": response.json()}
return decorated

420
helper.py
View File

@@ -1,79 +1,48 @@
import logging
import os
"""Helper functions used for formatting."""
import requests
from flask import Flask
import headscale
LOG_LEVEL = os.environ["LOG_LEVEL"].replace('"', "").upper()
# Initiate the Flask application and logging:
app = Flask(__name__, static_url_path="/static")
match LOG_LEVEL:
case "DEBUG":
app.logger.setLevel(logging.DEBUG)
case "INFO":
app.logger.setLevel(logging.INFO)
case "WARNING":
app.logger.setLevel(logging.WARNING)
case "ERROR":
app.logger.setLevel(logging.ERROR)
case "CRITICAL":
app.logger.setLevel(logging.CRITICAL)
from datetime import timedelta
from enum import StrEnum
from typing import Literal
def pretty_print_duration(duration, delta_type=""):
"""Prints a duration in human-readable formats"""
def pretty_print_duration(
duration: timedelta, delta_type: Literal["expiry", ""] = ""
): # pylint: disable=too-many-return-statements
"""Print a duration in human-readable format."""
days, seconds = duration.days, duration.seconds
hours = days * 24 + seconds // 3600
mins = (seconds % 3600) // 60
secs = seconds % 60
if delta_type == "expiry":
if days > 730:
return "in greater than two years"
return "in more than two years"
if days > 365:
return "in greater than a year"
return "in more than a year"
if days > 0:
return (
"in " + str(days) + " days" if days > 1 else "in " + str(days) + " day"
)
return f"in {days} days" if days > 1 else f"in {days} day"
if hours > 0:
return (
"in " + str(hours) + " hours"
if hours > 1
else "in " + str(hours) + " hour"
)
return f"in {hours} hours" if hours > 1 else f"in {hours} hour"
if mins > 0:
return (
"in " + str(mins) + " minutes"
if mins > 1
else "in " + str(mins) + " minute"
)
return (
"in " + str(secs) + " seconds"
if secs >= 1 or secs == 0
else "in " + str(secs) + " second"
)
return f"in {mins} minutes" if mins > 1 else f"in {mins} minute"
return f"in {secs} seconds" if secs >= 1 or secs == 0 else f"in {secs} second"
if days > 730:
return "over two years ago"
if days > 365:
return "over a year ago"
if days > 0:
return str(days) + " days ago" if days > 1 else str(days) + " day ago"
return f"{days} days ago" if days > 1 else f"{days} day ago"
if hours > 0:
return str(hours) + " hours ago" if hours > 1 else str(hours) + " hour ago"
return f"{hours} hours ago" if hours > 1 else f"{hours} hour ago"
if mins > 0:
return str(mins) + " minutes ago" if mins > 1 else str(mins) + " minute ago"
return (
str(secs) + " seconds ago"
if secs >= 1 or secs == 0
else str(secs) + " second ago"
)
return f"{mins} minutes ago" if mins > 1 else f"{mins} minute ago"
return f"{secs} seconds ago" if secs >= 1 or secs == 0 else f"{secs} second ago"
def text_color_duration(duration):
"""Prints a color based on duratioin (imported as seconds)"""
def text_color_duration(
duration: timedelta,
): # pylint: disable=too-many-return-statements
"""Print a color based on duration (imported as seconds)."""
days, seconds = duration.days, duration.seconds
hours = days * 24 + seconds // 3600
mins = (seconds % 3600) // 60
@@ -101,280 +70,83 @@ def text_color_duration(duration):
return "green-text "
def key_check():
"""Checks the validity of a Headsclae API key and renews it if it's nearing expiration"""
api_key = headscale.get_api_key()
url = headscale.get_url()
# Test the API key. If the test fails, return a failure.
# AKA, if headscale returns Unauthorized, fail:
app.logger.info("Testing API key validity.")
status = headscale.test_api_key(url, api_key)
if status != 200:
app.logger.info(
"Got a non-200 response from Headscale. Test failed (Response: %i)",
status,
)
return False
else:
app.logger.info("Key check passed.")
# Check if the key needs to be renewed
headscale.renew_api_key(url, api_key)
return True
def get_color(import_id, item_type=""):
"""Sets colors for users/namespaces"""
def get_color(import_id: int, item_type: Literal["failover", "text", ""] = ""):
"""Get color for users/namespaces."""
# Define the colors... Seems like a good number to start with
if item_type == "failover":
colors = [
"teal lighten-1",
"blue lighten-1",
"blue-grey lighten-1",
"indigo lighten-2",
"brown lighten-1",
"grey lighten-1",
"indigo lighten-2",
"deep-orange lighten-1",
"yellow lighten-2",
"purple lighten-2",
]
index = import_id % len(colors)
return colors[index]
if item_type == "text":
colors = [
"red-text text-lighten-1",
"teal-text text-lighten-1",
"blue-text text-lighten-1",
"blue-grey-text text-lighten-1",
"indigo-text text-lighten-2",
"green-text text-lighten-1",
"deep-orange-text text-lighten-1",
"yellow-text text-lighten-2",
"purple-text text-lighten-2",
"indigo-text text-lighten-2",
"brown-text text-lighten-1",
"grey-text text-lighten-1",
]
index = import_id % len(colors)
return colors[index]
colors = [
"red lighten-1",
"teal lighten-1",
"blue lighten-1",
"blue-grey lighten-1",
"indigo lighten-2",
"green lighten-1",
"deep-orange lighten-1",
"yellow lighten-2",
"purple lighten-2",
"indigo lighten-2",
"brown lighten-1",
"grey lighten-1",
]
index = import_id % len(colors)
return colors[index]
match item_type:
case "failover":
colors = [
"teal lighten-1",
"blue lighten-1",
"blue-grey lighten-1",
"indigo lighten-2",
"brown lighten-1",
"grey lighten-1",
"indigo lighten-2",
"deep-orange lighten-1",
"yellow lighten-2",
"purple lighten-2",
]
case "text":
colors = [
"red-text text-lighten-1",
"teal-text text-lighten-1",
"blue-text text-lighten-1",
"blue-grey-text text-lighten-1",
"indigo-text text-lighten-2",
"green-text text-lighten-1",
"deep-orange-text text-lighten-1",
"yellow-text text-lighten-2",
"purple-text text-lighten-2",
"indigo-text text-lighten-2",
"brown-text text-lighten-1",
"grey-text text-lighten-1",
]
case _:
colors = [
"red lighten-1",
"teal lighten-1",
"blue lighten-1",
"blue-grey lighten-1",
"indigo lighten-2",
"green lighten-1",
"deep-orange lighten-1",
"yellow lighten-2",
"purple lighten-2",
"indigo lighten-2",
"brown lighten-1",
"grey lighten-1",
]
return colors[import_id % len(colors)]
def format_message(error_type, title, message):
"""Defines a generic 'collection' as error/warning/info messages"""
content = """
<ul class="collection">
<li class="collection-item avatar">
"""
class MessageErrorType(StrEnum):
"""Error type for `format_message()."""
match error_type.lower():
case "warning":
icon = """<i class="material-icons circle yellow">priority_high</i>"""
title = """<span class="title">Warning - """ + title + """</span>"""
case "success":
icon = """<i class="material-icons circle green">check</i>"""
title = """<span class="title">Success - """ + title + """</span>"""
case "error":
icon = """<i class="material-icons circle red">warning</i>"""
title = """<span class="title">Error - """ + title + """</span>"""
case "information":
icon = """<i class="material-icons circle grey">help</i>"""
title = """<span class="title">Information - """ + title + """</span>"""
WARNING = "warning"
SUCCESS = "success"
ERROR = "error"
INFORMATION = "information"
content = content + icon + title + message
content = (
content
+ """
</li>
</ul>
"""
)
def format_message(error_type: MessageErrorType, title: str, message: str):
"""Render a "collection" as error/warning/info message."""
content = '<ul class="collection"><li class="collection-item avatar">'
match error_type:
case MessageErrorType.WARNING:
icon = '<i class="material-icons circle yellow">priority_high</i>'
title = f'<span class="title">Warning - {title}</span>'
case MessageErrorType.SUCCESS:
icon = '<i class="material-icons circle green">check</i>'
title = f'<span class="title">Success - {title}</span>'
case MessageErrorType.ERROR:
icon = '<i class="material-icons circle red">warning</i>'
title = f'<span class="title">Error - {title}</span>'
case MessageErrorType.INFORMATION:
icon = '<i class="material-icons circle grey">help</i>'
title = f'<span class="title">Information - {title}</span>'
content += icon + title + message + "</li></ul>"
return content
def access_checks():
"""Checks various items before each page load to ensure permissions are correct"""
url = headscale.get_url()
# Return an error message if things fail.
# Return a formatted error message for EACH fail.
checks_passed = True # Default to true. Set to false when any checks fail.
data_readable = False # Checks R permissions of /data
data_writable = False # Checks W permissions of /data
data_executable = False # Execute on directories allows file access
file_readable = False # Checks R permissions of /data/key.txt
file_writable = False # Checks W permissions of /data/key.txt
file_exists = False # Checks if /data/key.txt exists
config_readable = False # Checks if the headscale configuration file is readable
# Check 1: Check: the Headscale server is reachable:
server_reachable = False
response = requests.get(str(url) + "/health")
if response.status_code == 200:
server_reachable = True
else:
checks_passed = False
app.logger.critical("Headscale URL: Response 200: FAILED")
# Check: /data is rwx for 1000:1000:
if os.access("/data/", os.R_OK):
data_readable = True
else:
app.logger.critical("/data READ: FAILED")
checks_passed = False
if os.access("/data/", os.W_OK):
data_writable = True
else:
app.logger.critical("/data WRITE: FAILED")
checks_passed = False
if os.access("/data/", os.X_OK):
data_executable = True
else:
app.logger.critical("/data EXEC: FAILED")
checks_passed = False
# Check: /data/key.txt exists and is rw:
if os.access("/data/key.txt", os.F_OK):
file_exists = True
if os.access("/data/key.txt", os.R_OK):
file_readable = True
else:
app.logger.critical("/data/key.txt READ: FAILED")
checks_passed = False
if os.access("/data/key.txt", os.W_OK):
file_writable = True
else:
app.logger.critical("/data/key.txt WRITE: FAILED")
checks_passed = False
else:
app.logger.error("/data/key.txt EXIST: FAILED - NO ERROR")
# Check: /etc/headscale/config.yaml is readable:
if os.access("/etc/headscale/config.yaml", os.R_OK):
config_readable = True
elif os.access("/etc/headscale/config.yml", os.R_OK):
config_readable = True
else:
app.logger.error("/etc/headscale/config.y(a)ml: READ: FAILED")
checks_passed = False
if checks_passed:
app.logger.info("All startup checks passed.")
return "Pass"
message_html = ""
# Generate the message:
if not server_reachable:
app.logger.critical("Server is unreachable")
message = (
"""
<p>Your headscale server is either unreachable or not properly configured.
Please ensure your configuration is correct (Check for 200 status on
"""
+ url
+ """/api/v1 failed. Response: """
+ str(response.status_code)
+ """.)</p>
"""
)
message_html += format_message("Error", "Headscale unreachable", message)
if not config_readable:
app.logger.critical("Headscale configuration is not readable")
message = """
<p>/etc/headscale/config.yaml not readable. Please ensure your
headscale configuration file resides in /etc/headscale and
is named "config.yaml" or "config.yml"</p>
"""
message_html += format_message(
"Error", "/etc/headscale/config.yaml not readable", message
)
if not data_writable:
app.logger.critical("/data folder is not writable")
message = """
<p>/data is not writable. Please ensure your
permissions are correct. /data mount should be writable
by UID/GID 1000:1000.</p>
"""
message_html += format_message("Error", "/data not writable", message)
if not data_readable:
app.logger.critical("/data folder is not readable")
message = """
<p>/data is not readable. Please ensure your
permissions are correct. /data mount should be readable
by UID/GID 1000:1000.</p>
"""
message_html += format_message("Error", "/data not readable", message)
if not data_executable:
app.logger.critical("/data folder is not readable")
message = """
<p>/data is not executable. Please ensure your
permissions are correct. /data mount should be readable
by UID/GID 1000:1000. (chown 1000:1000 /path/to/data && chmod -R 755 /path/to/data)</p>
"""
message_html += format_message("Error", "/data not executable", message)
if file_exists:
# If it doesn't exist, we assume the user hasn't created it yet.
# Just redirect to the settings page to enter an API Key
if not file_writable:
app.logger.critical("/data/key.txt is not writable")
message = """
<p>/data/key.txt is not writable. Please ensure your
permissions are correct. /data mount should be writable
by UID/GID 1000:1000.</p>
"""
message_html += format_message(
"Error", "/data/key.txt not writable", message
)
if not file_readable:
app.logger.critical("/data/key.txt is not readable")
message = """
<p>/data/key.txt is not readable. Please ensure your
permissions are correct. /data mount should be readable
by UID/GID 1000:1000.</p>
"""
message_html += format_message(
"Error", "/data/key.txt not readable", message
)
return message_html
def load_checks():
"""Bundles all the checks into a single function to call easier"""
# General error checks. See the function for more info:
if access_checks() != "Pass":
return "error_page"
# If the API key fails, redirect to the settings page:
if not key_check():
return "settings_page"
return "Pass"

1131
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,20 +4,26 @@ version = "v0.6.1"
description = "A simple web UI for small-scale Headscale deployments."
authors = ["Albert Copeland <albert@sysctl.io>"]
license = "AGPL"
packages = [
{ include = "*.py" }
]
readme = "README.md"
repository = "https://github.com/iFargle/headscale-webui"
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.28.2"
Flask = "^2.2.2"
Flask = {extras = ["async"], version = "^2.2.3"}
cryptography = "^39.0.0"
python-dateutil = "^2.8.2"
pytz = "^2022.7.1"
Flask-Executor = "^1.0.0"
PyYAML = "^6.0"
pyuwsgi = "^2.0.21"
gunicorn = "^20.1.0"
flask-basicauth = "^0.2.0"
flask-providers-oidc = "^1.2.1"
flask-pydantic = {git = "https://github.com/MarekPikula/flask-pydantic.git", rev = "dictable_models"}
headscale-api = {git = "https://github.com/MarekPikula/python-headscale-api.git"}
betterproto = {git = "https://github.com/MarekPikula/python-betterproto.git", rev = "classmethod_from_dict"}
apscheduler = "^3.10.1"
tzdata = "^2023.3"
[tool.poetry.group.dev.dependencies]
pylint = "^2.17.0"
@@ -27,9 +33,18 @@ ruff = "^0.0.260"
pre-commit = "^3.2.1"
mypy = "^1.1.1"
pydocstyle = "^6.3.0"
pylint-pydantic = "^0.1.8"
types-requests = "^2.28.11.17"
coverage = "^7.2.3"
gitpython = "^3.1.31"
[build-system]
requires = ["poetry-core>=1.0.0"]
[tool.isort]
profile = "black"
[tool.pylint.main]
extension-pkg-whitelist = ["pydantic"]
load-plugins = ["pylint_pydantic"]
generated-members = "app.logger.debug,\napp.logger.info,\napp.logger.warning,\napp.logger.error,\napp.logger.critical,\napp.logger.exception,\napp.logger.setLevel"

File diff suppressed because it is too large Load Diff

903
server.py
View File

@@ -1,614 +1,399 @@
import json
import logging
import os
import secrets
from datetime import datetime
from functools import wraps
"""Headscale WebUI Flask server."""
import pytz
import requests
from dateutil import parser
from flask import Flask, Markup, escape, redirect, render_template, request, url_for
from flask_executor import Executor
import asyncio
import atexit
import datetime
import functools
from multiprocessing import Lock
from typing import Awaitable, Callable, Type, TypeVar
import headscale_api.schema.headscale.v1 as schema
from aiohttp import ClientConnectionError
from apscheduler.schedulers.background import BackgroundScheduler # type: ignore
from betterproto import Message
from flask import Flask, redirect, render_template, url_for
from flask_pydantic.core import validate
from headscale_api.headscale import UnauthorizedError
from markupsafe import Markup
from pydantic import BaseModel, Field
from werkzeug.middleware.proxy_fix import ProxyFix
import headscale
import helper
import renderer
from auth import AuthManager
from config import Config, InitCheckError
from headscale import HeadscaleApi
# Global vars
# Colors: https://materializecss.com/color.html
COLOR = os.environ["COLOR"].replace('"', "").lower()
COLOR_NAV = COLOR + " darken-1"
COLOR_BTN = COLOR + " darken-3"
AUTH_TYPE = os.environ["AUTH_TYPE"].replace('"', "").lower()
LOG_LEVEL = os.environ["LOG_LEVEL"].replace('"', "").upper()
# If LOG_LEVEL is DEBUG, enable Flask debugging:
DEBUG_STATE = True if LOG_LEVEL == "DEBUG" else False
# Initiate the Flask application and logging:
app = Flask(__name__, static_url_path="/static")
match LOG_LEVEL:
case "DEBUG":
app.logger.setLevel(logging.DEBUG)
case "INFO":
app.logger.setLevel(logging.INFO)
case "WARNING":
app.logger.setLevel(logging.WARNING)
case "ERROR":
app.logger.setLevel(logging.ERROR)
case "CRITICAL":
app.logger.setLevel(logging.CRITICAL)
executor = Executor(app)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
app.logger.info(
"Headscale-WebUI Version: "
+ os.environ["APP_VERSION"]
+ " / "
+ os.environ["GIT_BRANCH"]
)
app.logger.info("LOG LEVEL SET TO %s", str(LOG_LEVEL))
app.logger.info("DEBUG STATE: %s", str(DEBUG_STATE))
########################################################################################
# Set Authentication type. Currently "OIDC" and "BASIC"
########################################################################################
if AUTH_TYPE == "oidc":
# Currently using: flask-providers-oidc - https://pypi.org/project/flask-providers-oidc/
#
# https://gist.github.com/thomasdarimont/145dc9aa857b831ff2eff221b79d179a/
# https://www.authelia.com/integration/openid-connect/introduction/
# https://github.com/steinarvk/flask_oidc_demo
app.logger.info("Loading OIDC libraries and configuring app...")
DOMAIN_NAME = os.environ["DOMAIN_NAME"]
BASE_PATH = os.environ["SCRIPT_NAME"] if os.environ["SCRIPT_NAME"] != "/" else ""
OIDC_SECRET = os.environ["OIDC_CLIENT_SECRET"]
OIDC_CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
OIDC_AUTH_URL = os.environ["OIDC_AUTH_URL"]
# Construct client_secrets.json:
response = requests.get(str(OIDC_AUTH_URL))
oidc_info = response.json()
app.logger.debug("JSON Dumps for OIDC_INFO: " + json.dumps(oidc_info))
client_secrets = json.dumps(
{
"web": {
"issuer": oidc_info["issuer"],
"auth_uri": oidc_info["authorization_endpoint"],
"client_id": OIDC_CLIENT_ID,
"client_secret": OIDC_SECRET,
"redirect_uris": [DOMAIN_NAME + BASE_PATH + "/oidc_callback"],
"userinfo_uri": oidc_info["userinfo_endpoint"],
"token_uri": oidc_info["token_endpoint"],
}
}
def create_tainted_app(app: Flask, error: InitCheckError) -> Flask:
"""Run tainted version of the Headscale WebUI after encountering an error."""
app.logger.error(
"Encountered error when trying to run initialization checks. Running in "
"tainted mode (only the error page is available). Correct all errors and "
"restart the server."
)
with open("/app/instance/secrets.json", "w+") as secrets_json:
secrets_json.write(client_secrets)
app.logger.debug("Client Secrets: ")
with open("/app/instance/secrets.json", "r+") as secrets_json:
app.logger.debug("/app/instances/secrets.json:")
app.logger.debug(secrets_json.read())
@app.route("/<path:path>")
def catchall_redirect(path: str): # pylint: disable=unused-argument
return redirect(url_for("error_page"))
app.config.update(
{
"SECRET_KEY": secrets.token_urlsafe(32),
"TESTING": DEBUG_STATE,
"DEBUG": DEBUG_STATE,
"OIDC_CLIENT_SECRETS": "/app/instance/secrets.json",
"OIDC_ID_TOKEN_COOKIE_SECURE": True,
"OIDC_REQUIRE_VERIFIED_EMAIL": False,
"OIDC_USER_INFO_ENABLED": True,
"OIDC_OPENID_REALM": "Headscale-WebUI",
"OIDC_SCOPES": ["openid", "profile", "email"],
"OIDC_INTROSPECTION_AUTH_METHOD": "client_secret_post",
}
@app.route("/error")
async def error_page():
return render_template(
"error.html",
error_message=Markup(
"".join(sub_error.format_message() for sub_error in error)
),
)
return app
async def create_app() -> Flask:
"""Run Headscale WebUI Flask application.
For arguments refer to `Flask.run()` function.
"""
app = Flask(__name__, static_url_path="/static")
app.wsgi_app = ProxyFix( # type: ignore[method-assign]
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1 # type: ignore
)
from flask_oidc import OpenIDConnect
try:
# Try to initialize configuration from environment.
config = Config() # type: ignore
oidc = OpenIDConnect(app)
with app.app_context():
# Try to create authentication handler (including loading auth config).
auth = AuthManager(config)
elif AUTH_TYPE == "basic":
# https://flask-basicauth.readthedocs.io/en/latest/
app.logger.info("Loading basic auth libraries and configuring app...")
from flask_basicauth import BasicAuth
# Try to create Headscale API interface.
headscale = HeadscaleApi(config)
app.config["BASIC_AUTH_USERNAME"] = os.environ["BASIC_AUTH_USER"].replace('"', "")
app.config["BASIC_AUTH_PASSWORD"] = os.environ["BASIC_AUTH_PASS"]
app.config["BASIC_AUTH_FORCE"] = True
# Check health of Headscale API.
if not await headscale.health_check():
raise ClientConnectionError(f"Health check failed on {headscale.base_url}")
except Exception as error: # pylint: disable=broad-exception-caught
# We want to catch broad exception to ensure no errors whatsoever went through
# the environment init.
with app.app_context():
check_error = InitCheckError.from_exception(error)
return create_tainted_app(app, check_error)
basic_auth = BasicAuth(app)
app.logger.setLevel(config.log_level)
app.logger.info(
"Headscale-WebUI Version: %s / %s", config.app_version, config.git_branch
)
app.logger.info("Logger level set to %s.", config.log_level)
app.logger.info("Debug state: %s", config.debug_mode)
########################################################################################
# Set Authentication type - Dynamically load function decorators
# https://stackoverflow.com/questions/17256602/assertionerror-view-function-mapping-is-overwriting-an-existing-endpoint-functi
########################################################################################
# Make a fake decorator for oidc.require_login
# If anyone knows a better way of doing this, please let me know.
class OpenIDConnect:
def require_login(self, view_func):
@wraps(view_func)
def decorated(*args, **kwargs):
return view_func(*args, **kwargs)
register_pages(app, headscale, auth)
register_api_endpoints(app, headscale, auth)
register_scheduler(app, headscale)
return decorated
oidc = OpenIDConnect()
else:
########################################################################################
# Set Authentication type - Dynamically load function decorators
# https://stackoverflow.com/questions/17256602/assertionerror-view-function-mapping-is-overwriting-an-existing-endpoint-functi
########################################################################################
# Make a fake decorator for oidc.require_login
# If anyone knows a better way of doing this, please let me know.
class OpenIDConnect:
def require_login(self, view_func):
@wraps(view_func)
def decorated(*args, **kwargs):
return view_func(*args, **kwargs)
return decorated
oidc = OpenIDConnect()
return app
########################################################################################
# / pages - User-facing pages
########################################################################################
@app.route("/")
@app.route("/overview")
@oidc.require_login
def overview_page():
# Some basic sanity checks:
pass_checks = str(helper.load_checks())
if pass_checks != "Pass":
return redirect(url_for(pass_checks))
def register_pages(app: Flask, headscale: HeadscaleApi, auth: AuthManager):
"""Register user-facing pages."""
config = headscale.app_config
# Check if OIDC is enabled. If it is, display the buttons:
OIDC_NAV_DROPDOWN = Markup("")
OIDC_NAV_MOBILE = Markup("")
if AUTH_TYPE == "oidc":
email_address = oidc.user_getfield("email")
user_name = oidc.user_getfield("preferred_username")
name = oidc.user_getfield("name")
OIDC_NAV_DROPDOWN = renderer.oidc_nav_dropdown(user_name, email_address, name)
OIDC_NAV_MOBILE = renderer.oidc_nav_mobile(user_name, email_address, name)
return render_template(
"overview.html",
render_page=renderer.render_overview(),
COLOR_NAV=COLOR_NAV,
COLOR_BTN=COLOR_BTN,
OIDC_NAV_DROPDOWN=OIDC_NAV_DROPDOWN,
OIDC_NAV_MOBILE=OIDC_NAV_MOBILE,
# Convenience short for render_defaults
render_defaults = functools.partial(
renderer.render_defaults, config, auth.oidc_handler
)
@app.route("/")
@app.route("/overview")
@auth.require_login
@headscale.key_check_guard
async def overview_page():
return render_template(
"overview.html",
render_page=await renderer.render_overview(headscale),
**render_defaults(),
)
@app.route("/routes", methods=("GET", "POST"))
@oidc.require_login
def routes_page():
# Some basic sanity checks:
pass_checks = str(helper.load_checks())
if pass_checks != "Pass":
return redirect(url_for(pass_checks))
@app.route("/routes", methods=("GET", "POST"))
@auth.require_login
@headscale.key_check_guard
async def routes_page():
return render_template(
"routes.html",
render_page=await renderer.render_routes(headscale),
**render_defaults(),
)
# Check if OIDC is enabled. If it is, display the buttons:
OIDC_NAV_DROPDOWN = Markup("")
OIDC_NAV_MOBILE = Markup("")
INPAGE_SEARCH = Markup(renderer.render_search())
if AUTH_TYPE == "oidc":
email_address = oidc.user_getfield("email")
user_name = oidc.user_getfield("preferred_username")
name = oidc.user_getfield("name")
OIDC_NAV_DROPDOWN = renderer.oidc_nav_dropdown(user_name, email_address, name)
OIDC_NAV_MOBILE = renderer.oidc_nav_mobile(user_name, email_address, name)
@app.route("/machines", methods=("GET", "POST"))
@auth.require_login
@headscale.key_check_guard
async def machines_page():
return render_template(
"machines.html",
cards=await renderer.render_machines_cards(headscale),
headscale_server=config.hs_server,
inpage_search=renderer.render_search(),
**render_defaults(),
)
return render_template(
"routes.html",
render_page=renderer.render_routes(),
COLOR_NAV=COLOR_NAV,
COLOR_BTN=COLOR_BTN,
OIDC_NAV_DROPDOWN=OIDC_NAV_DROPDOWN,
OIDC_NAV_MOBILE=OIDC_NAV_MOBILE,
)
@app.route("/users", methods=("GET", "POST"))
@auth.require_login
@headscale.key_check_guard
async def users_page():
return render_template(
"users.html",
cards=await renderer.render_users_cards(headscale),
inpage_search=renderer.render_search(),
)
@app.route("/settings", methods=("GET", "POST"))
@auth.require_login
async def settings_page():
return render_template(
"settings.html",
url=headscale.base_url,
BUILD_DATE=config.build_date,
APP_VERSION=config.app_version,
GIT_REPO_URL=config.git_repo_url,
GIT_COMMIT=config.git_commit,
GIT_BRANCH=config.git_branch,
HS_VERSION=config.hs_version,
**render_defaults(),
)
@app.route("/machines", methods=("GET", "POST"))
@oidc.require_login
def machines_page():
# Some basic sanity checks:
pass_checks = str(helper.load_checks())
if pass_checks != "Pass":
return redirect(url_for(pass_checks))
@app.route("/error")
async def error_page():
"""Error page redirect.
# Check if OIDC is enabled. If it is, display the buttons:
OIDC_NAV_DROPDOWN = Markup("")
OIDC_NAV_MOBILE = Markup("")
INPAGE_SEARCH = Markup(renderer.render_search())
if AUTH_TYPE == "oidc":
email_address = oidc.user_getfield("email")
user_name = oidc.user_getfield("preferred_username")
name = oidc.user_getfield("name")
OIDC_NAV_DROPDOWN = renderer.oidc_nav_dropdown(user_name, email_address, name)
OIDC_NAV_MOBILE = renderer.oidc_nav_mobile(user_name, email_address, name)
cards = renderer.render_machines_cards()
return render_template(
"machines.html",
cards=cards,
headscale_server=headscale.get_url(True),
COLOR_NAV=COLOR_NAV,
COLOR_BTN=COLOR_BTN,
OIDC_NAV_DROPDOWN=OIDC_NAV_DROPDOWN,
OIDC_NAV_MOBILE=OIDC_NAV_MOBILE,
INPAGE_SEARCH=INPAGE_SEARCH,
)
@app.route("/users", methods=("GET", "POST"))
@oidc.require_login
def users_page():
# Some basic sanity checks:
pass_checks = str(helper.load_checks())
if pass_checks != "Pass":
return redirect(url_for(pass_checks))
# Check if OIDC is enabled. If it is, display the buttons:
OIDC_NAV_DROPDOWN = Markup("")
OIDC_NAV_MOBILE = Markup("")
INPAGE_SEARCH = Markup(renderer.render_search())
if AUTH_TYPE == "oidc":
email_address = oidc.user_getfield("email")
user_name = oidc.user_getfield("preferred_username")
name = oidc.user_getfield("name")
OIDC_NAV_DROPDOWN = renderer.oidc_nav_dropdown(user_name, email_address, name)
OIDC_NAV_MOBILE = renderer.oidc_nav_mobile(user_name, email_address, name)
cards = renderer.render_users_cards()
return render_template(
"users.html",
cards=cards,
COLOR_NAV=COLOR_NAV,
COLOR_BTN=COLOR_BTN,
OIDC_NAV_DROPDOWN=OIDC_NAV_DROPDOWN,
OIDC_NAV_MOBILE=OIDC_NAV_MOBILE,
INPAGE_SEARCH=INPAGE_SEARCH,
)
@app.route("/settings", methods=("GET", "POST"))
@oidc.require_login
def settings_page():
# Some basic sanity checks:
pass_checks = str(helper.load_checks())
if pass_checks != "Pass" and pass_checks != "settings_page":
return redirect(url_for(pass_checks))
# Check if OIDC is enabled. If it is, display the buttons:
OIDC_NAV_DROPDOWN = Markup("")
OIDC_NAV_MOBILE = Markup("")
if AUTH_TYPE == "oidc":
email_address = oidc.user_getfield("email")
user_name = oidc.user_getfield("preferred_username")
name = oidc.user_getfield("name")
OIDC_NAV_DROPDOWN = renderer.oidc_nav_dropdown(user_name, email_address, name)
OIDC_NAV_MOBILE = renderer.oidc_nav_mobile(user_name, email_address, name)
GIT_COMMIT_LINK = Markup(
"<a href='https://github.com/iFargle/headscale-webui/commit/"
+ os.environ["GIT_COMMIT"]
+ "'>"
+ str(os.environ["GIT_COMMIT"])[0:7]
+ "</a>"
)
return render_template(
"settings.html",
url=headscale.get_url(),
COLOR_NAV=COLOR_NAV,
COLOR_BTN=COLOR_BTN,
OIDC_NAV_DROPDOWN=OIDC_NAV_DROPDOWN,
OIDC_NAV_MOBILE=OIDC_NAV_MOBILE,
BUILD_DATE=os.environ["BUILD_DATE"],
APP_VERSION=os.environ["APP_VERSION"],
GIT_COMMIT=GIT_COMMIT_LINK,
GIT_BRANCH=os.environ["GIT_BRANCH"],
HS_VERSION=os.environ["HS_VERSION"],
)
@app.route("/error")
@oidc.require_login
def error_page():
if helper.access_checks() == "Pass":
Once we get out of tainted mode, we want to still have this route active so that
users refreshing the page get redirected to the overview page.
"""
return redirect(url_for("overview_page"))
return render_template("error.html", ERROR_MESSAGE=Markup(helper.access_checks()))
@app.route("/logout")
@auth.require_login
@headscale.key_check_guard
async def logout_page():
logout_url = auth.logout()
if logout_url is not None:
return redirect(logout_url)
return redirect(url_for("overview_page"))
@app.route("/logout")
def logout_page():
if AUTH_TYPE == "oidc":
oidc.logout()
return redirect(url_for("overview_page"))
def register_api_endpoints(app: Flask, headscale: HeadscaleApi, auth: AuthManager):
"""Register Headscale WebUI API endpoints."""
RequestT = TypeVar("RequestT", bound=Message)
ResponseT = TypeVar("ResponseT", bound=Message)
def api_passthrough(
route: str,
request_type: Type[RequestT],
api_method: Callable[[RequestT], Awaitable[ResponseT | str]],
):
"""Passthrough the Headscale API in a concise form.
########################################################################################
# /api pages
########################################################################################
Arguments:
route -- Flask route to the API endpoint.
request_type -- request model (from headscale_api.schema).
api_method -- backend method to pass through the Flask request.
"""
########################################################################################
# Headscale API Key Endpoints
########################################################################################
async def api_passthrough_page(body: RequestT) -> ResponseT | str:
return await api_method(body) # type: ignore
api_passthrough_page.__name__ = route.replace("/", "_")
api_passthrough_page.__annotations__ = {"body": request_type}
@app.route("/api/test_key", methods=("GET", "POST"))
@oidc.require_login
def test_key_page():
api_key = headscale.get_api_key()
url = headscale.get_url()
return app.route(route, methods=["POST"])(
auth.require_login(
headscale.key_check_guard(
validate()(api_passthrough_page) # type: ignore
)
)
)
# Test the API key. If the test fails, return a failure.
status = headscale.test_api_key(url, api_key)
if status != 200:
return "Unauthenticated"
class TestKeyRequest(BaseModel):
"""/api/test_key request."""
renewed = headscale.renew_api_key(url, api_key)
app.logger.warning("The below statement will be TRUE if the key has been renewed, ")
app.logger.warning("or DOES NOT need renewal. False in all other cases")
app.logger.warning("Renewed: " + str(renewed))
# The key works, let's renew it if it needs it. If it does, re-read the api_key from the file:
if renewed:
api_key = headscale.get_api_key()
api_key: str | None = Field(
None, description="API key to test. If None test the current key."
)
key_info = headscale.get_api_key_info(url, api_key)
@app.route("/api/test_key", methods=("GET", "POST"))
@auth.require_login
@validate()
async def test_key_page(body: TestKeyRequest):
if body.api_key == "":
body.api_key = None
# Set the current timezone and local time
timezone = pytz.timezone(os.environ["TZ"] if os.environ["TZ"] else "UTC")
local_time = timezone.localize(datetime.now())
async with headscale.session:
if not await headscale.test_api_key(body.api_key):
return "Unauthenticated", 401
# Format the dates for easy readability
creation_parse = parser.parse(key_info["createdAt"])
creation_local = creation_parse.astimezone(timezone)
creation_delta = local_time - creation_local
creation_print = helper.pretty_print_duration(creation_delta)
creation_time = (
str(creation_local.strftime("%A %m/%d/%Y, %H:%M:%S"))
+ " "
+ str(timezone)
+ " ("
+ str(creation_print)
+ ")"
ret = await headscale.renew_api_key()
match ret:
case None:
return "Unauthenticated", 401
case schema.ApiKey():
return ret
case _:
new_key_info = await headscale.get_api_key_info()
if new_key_info is None:
return "Unauthenticated", 401
return new_key_info
class SaveKeyRequest(BaseModel):
"""/api/save_key request."""
api_key: str
@app.route("/api/save_key", methods=["POST"])
@auth.require_login
@validate()
async def save_key_page(body: SaveKeyRequest):
async with headscale.session:
# Test the new API key.
if not await headscale.test_api_key(body.api_key):
return "Key failed testing. Check your key.", 401
try:
headscale.api_key = body.api_key
except OSError:
return "Key did not save properly. Check logs.", 500
key_info = await headscale.get_api_key_info()
if key_info is None:
return "Key saved but error occurred on key info retrieval."
return (
f'Key saved and tested: Key: "{key_info.prefix}", '
f"expiration: {key_info.expiration}"
)
####################################################################################
# Machine API Endpoints
####################################################################################
class UpdateRoutePageRequest(BaseModel):
"""/api/update_route request."""
route_id: int
current_state: bool
@app.route("/api/update_route", methods=["POST"])
@auth.require_login
@validate()
async def update_route_page(body: UpdateRoutePageRequest):
if body.current_state:
return await headscale.disable_route(
schema.DisableRouteRequest(body.route_id)
)
return await headscale.enable_route(schema.EnableRouteRequest(body.route_id))
api_passthrough(
"/api/machine_information",
schema.GetMachineRequest,
headscale.get_machine,
)
api_passthrough(
"/api/delete_machine",
schema.DeleteMachineRequest,
headscale.delete_machine,
)
api_passthrough(
"/api/rename_machine",
schema.RenameMachineRequest,
headscale.rename_machine,
)
api_passthrough(
"/api/move_user",
schema.MoveMachineRequest,
headscale.move_machine,
)
api_passthrough("/api/set_machine_tags", schema.SetTagsRequest, headscale.set_tags)
api_passthrough(
"/api/register_machine",
schema.RegisterMachineRequest,
headscale.register_machine,
)
expiration_parse = parser.parse(key_info["expiration"])
expiration_local = expiration_parse.astimezone(timezone)
expiration_delta = expiration_local - local_time
expiration_print = helper.pretty_print_duration(expiration_delta, "expiry")
expiration_time = (
str(expiration_local.strftime("%A %m/%d/%Y, %H:%M:%S"))
+ " "
+ str(timezone)
+ " ("
+ str(expiration_print)
+ ")"
####################################################################################
# User API Endpoints
####################################################################################
api_passthrough("/api/rename_user", schema.RenameUserRequest, headscale.rename_user)
api_passthrough("/api/add_user", schema.CreateUserRequest, headscale.create_user)
api_passthrough("/api/delete_user", schema.DeleteUserRequest, headscale.delete_user)
api_passthrough("/api/get_users", schema.ListUsersRequest, headscale.list_users)
####################################################################################
# Pre-Auth Key API Endpoints
####################################################################################
api_passthrough(
"/api/add_preauth_key",
schema.CreatePreAuthKeyRequest,
headscale.create_pre_auth_key,
)
api_passthrough(
"/api/expire_preauth_key",
schema.ExpirePreAuthKeyRequest,
headscale.expire_pre_auth_key,
)
api_passthrough(
"/api/build_preauthkey_table",
schema.ListPreAuthKeysRequest,
functools.partial(renderer.build_preauth_key_table, headscale),
)
key_info["expiration"] = expiration_time
key_info["createdAt"] = creation_time
####################################################################################
# Route API Endpoints
####################################################################################
message = json.dumps(key_info)
return message
api_passthrough("/api/get_routes", schema.GetRoutesRequest, headscale.get_routes)
@app.route("/api/save_key", methods=["POST"])
@oidc.require_login
def save_key_page():
json_response = request.get_json()
api_key = json_response["api_key"]
url = headscale.get_url()
file_written = headscale.set_api_key(api_key)
message = ""
if file_written:
# Re-read the file and get the new API key and test it
api_key = headscale.get_api_key()
test_status = headscale.test_api_key(url, api_key)
if test_status == 200:
key_info = headscale.get_api_key_info(url, api_key)
expiration = key_info["expiration"]
message = "Key: '" + api_key + "', Expiration: " + expiration
# If the key was saved successfully, test it:
return "Key saved and tested: " + message
else:
return "Key failed testing. Check your key"
else:
return "Key did not save properly. Check logs"
scheduler_registered: bool = False
scheduler_lock = Lock()
########################################################################################
# Machine API Endpoints
########################################################################################
@app.route("/api/update_route", methods=["POST"])
@oidc.require_login
def update_route_page():
json_response = request.get_json()
route_id = escape(json_response["route_id"])
url = headscale.get_url()
api_key = headscale.get_api_key()
current_state = json_response["current_state"]
def register_scheduler(app: Flask, headscale: HeadscaleApi):
"""Register background scheduler."""
global scheduler_registered # pylint: disable=global-statement
return headscale.update_route(url, api_key, route_id, current_state)
with scheduler_lock:
if scheduler_registered:
# For multi-worker set-up, only a single scheduler needs to be enabled.
return
scheduler = BackgroundScheduler(
logger=app.logger, timezone=headscale.app_config.timezone
)
scheduler.start() # type: ignore
def renew_api_key():
"""Renew API key in a background job."""
app.logger.info("Key renewal schedule triggered...")
try:
if app.ensure_sync(headscale.renew_api_key)() is None: # type: ignore
app.logger.error("Failed to renew the key. Check configuration.")
except UnauthorizedError:
app.logger.error("Current key is invalid. Check configuration.")
scheduler.add_job( # type: ignore
renew_api_key,
"interval",
hours=1,
id="renew_api_key",
max_instances=1,
next_run_time=datetime.datetime.now(),
)
atexit.register(scheduler.shutdown) # type: ignore
scheduler_registered = True
@app.route("/api/machine_information", methods=["POST"])
@oidc.require_login
def machine_information_page():
json_response = request.get_json()
machine_id = escape(json_response["id"])
url = headscale.get_url()
api_key = headscale.get_api_key()
headscale_webui = asyncio.run(create_app())
return headscale.get_machine_info(url, api_key, machine_id)
@app.route("/api/delete_machine", methods=["POST"])
@oidc.require_login
def delete_machine_page():
json_response = request.get_json()
machine_id = escape(json_response["id"])
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.delete_machine(url, api_key, machine_id)
@app.route("/api/rename_machine", methods=["POST"])
@oidc.require_login
def rename_machine_page():
json_response = request.get_json()
machine_id = escape(json_response["id"])
new_name = escape(json_response["new_name"])
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.rename_machine(url, api_key, machine_id, new_name)
@app.route("/api/move_user", methods=["POST"])
@oidc.require_login
def move_user_page():
json_response = request.get_json()
machine_id = escape(json_response["id"])
new_user = escape(json_response["new_user"])
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.move_user(url, api_key, machine_id, new_user)
@app.route("/api/set_machine_tags", methods=["POST"])
@oidc.require_login
def set_machine_tags():
json_response = request.get_json()
machine_id = escape(json_response["id"])
machine_tags = json_response["tags_list"]
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.set_machine_tags(url, api_key, machine_id, machine_tags)
@app.route("/api/register_machine", methods=["POST"])
@oidc.require_login
def register_machine():
json_response = request.get_json()
machine_key = escape(json_response["key"])
user = escape(json_response["user"])
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.register_machine(url, api_key, machine_key, user)
########################################################################################
# User API Endpoints
########################################################################################
@app.route("/api/rename_user", methods=["POST"])
@oidc.require_login
def rename_user_page():
json_response = request.get_json()
old_name = escape(json_response["old_name"])
new_name = escape(json_response["new_name"])
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.rename_user(url, api_key, old_name, new_name)
@app.route("/api/add_user", methods=["POST"])
@oidc.require_login
def add_user():
json_response = request.get_json()
user_name = str(escape(json_response["name"]))
url = headscale.get_url()
api_key = headscale.get_api_key()
json_string = '{"name": "' + user_name + '"}'
return headscale.add_user(url, api_key, json_string)
@app.route("/api/delete_user", methods=["POST"])
@oidc.require_login
def delete_user():
json_response = request.get_json()
user_name = str(escape(json_response["name"]))
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.delete_user(url, api_key, user_name)
@app.route("/api/get_users", methods=["POST"])
@oidc.require_login
def get_users_page():
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.get_users(url, api_key)
########################################################################################
# Pre-Auth Key API Endpoints
########################################################################################
@app.route("/api/add_preauth_key", methods=["POST"])
@oidc.require_login
def add_preauth_key():
json_response = json.dumps(request.get_json())
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.add_preauth_key(url, api_key, json_response)
@app.route("/api/expire_preauth_key", methods=["POST"])
@oidc.require_login
def expire_preauth_key():
json_response = json.dumps(request.get_json())
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.expire_preauth_key(url, api_key, json_response)
@app.route("/api/build_preauthkey_table", methods=["POST"])
@oidc.require_login
def build_preauth_key_table():
json_response = request.get_json()
user_name = str(escape(json_response["name"]))
return renderer.build_preauth_key_table(user_name)
########################################################################################
# Route API Endpoints
########################################################################################
@app.route("/api/get_routes", methods=["POST"])
@oidc.require_login
def get_route_info():
url = headscale.get_url()
api_key = headscale.get_api_key()
return headscale.get_routes(url, api_key)
########################################################################################
# Main thread
########################################################################################
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=DEBUG_STATE)
headscale_webui.run(host="0.0.0.0")

View File

@@ -165,55 +165,54 @@ document.addEventListener('DOMContentLoaded', function () {
//-----------------------------------------------------------
function test_key() {
document.getElementById('test_modal_results').innerHTML = loading()
var api_key = document.getElementById('api_key').value;
var data = $.ajax({
type: "GET",
type: "POST",
url: "api/test_key",
data: JSON.stringify({ "api_key": api_key }),
contentType: "application/json",
success: function (response) {
if (response == "Unauthenticated") {
html = `
document.getElementById('test_modal_results').innerHTML = `
<ul class="collection">
<li class="collection-item avatar">
<i class="material-icons circle red">warning</i>
<span class="title">Error</span>
<p>Key authentication failed. Check your key.</p>
<i class="material-icons circle green">check</i>
<span class="title">Success</span>
<p>Key authenticated with the Headscale server.</p>
</li>
</ul>
<h6>Key Information</h6>
<table class="highlight">
<tbody>
<tr>
<td><b>Key ID</b></td>
<td>${response.id}</td>
</tr>
<tr>
<td><b>Prefix</b></td>
<td>${response.prefix}</td>
</tr>
<tr>
<td><b>Expiration Date</b></td>
<td>${response.expiration}</td>
</tr>
<tr>
<td><b>Creation Date</b></td>
<td>${response.createdAt}</td>
</tr>
</tbody>
</table>
`
document.getElementById('test_modal_results').innerHTML = html
} else {
json = JSON.parse(response)
var html = `
<ul class="collection">
<li class="collection-item avatar">
<i class="material-icons circle green">check</i>
<span class="title">Success</span>
<p>Key authenticated with the Headscale server.</p>
</li>
</ul>
<h6>Key Information</h6>
<table class="highlight">
<tbody>
<tr>
<td><b>Key ID</b></td>
<td>${json['id']}</td>
</tr>
<tr>
<td><b>Prefix</b></td>
<td>${json['prefix']}</td>
</tr>
<tr>
<td><b>Expiration Date</b></td>
<td>${json['expiration']}</td>
</tr>
<tr>
<td><b>Creation Date</b></td>
<td>${json['createdAt']}</td>
</tr>
</tbody>
</table>
`
document.getElementById('test_modal_results').innerHTML = html
}
},
error: function (xhr, textStatus, errorThrown) {
document.getElementById('test_modal_results').innerHTML = `
<ul class="collection">
<li class="collection-item avatar">
<i class="material-icons circle red">warning</i>
<span class="title">Error</span>
<p>Key authentication failed. Check your key.</p>
</li>
</ul>
`
}
})
@@ -241,7 +240,11 @@ function save_key() {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
M.toast({ html: 'Key saved. Testing...' });
M.toast({ html: 'Testing key and saving...' });
test_key();
},
error: function (xhr, textStatus, errorThrown) {
M.toast({ html: xhr.responseText })
test_key();
}
})
@@ -328,8 +331,8 @@ function load_modal_add_preauth_key(user_name) {
<p>
<ul>
<li>Pre-Auth keys can be used to authenticate to Headscale without manually registering a machine. Use the flag <code>--auth-key</code> to do so.</li>
<li>"Ephemeral" keys can be used to register devices that frequently come on and drop off the newtork (for example, docker containers)</li>
<li>Keys that are "Reusable" can be used multiple times. Keys that are "One Time Use" will expire after their first use.</li>
<li>"Ephemeral" keys can be used to register devices that frequently come on and drop off the network (for example, docker containers)</li>
<li>Keys that are "Reusable" can be used multiple times. Keys that are "One Time Use" will expire after their first use.</li>
</ul>
</p>
</li>
@@ -390,7 +393,7 @@ function load_modal_move_machine(machine_id) {
document.getElementById('modal_confirm').className = "green btn-flat white-text"
document.getElementById('modal_confirm').innerText = "Move"
var data = { "id": machine_id }
var data = { "machine_id": machine_id }
$.ajax({
type: "POST",
url: "api/machine_information",
@@ -400,6 +403,8 @@ function load_modal_move_machine(machine_id) {
$.ajax({
type: "POST",
url: "api/get_users",
data: "{}",
contentType: "application/json",
success: function (response) {
modal = document.getElementById('card_modal');
modal_title = document.getElementById('modal_title');
@@ -458,7 +463,7 @@ function load_modal_delete_machine(machine_id) {
document.getElementById('modal_confirm').className = "red btn-flat white-text"
document.getElementById('modal_confirm').innerText = "Delete"
var data = { "id": machine_id }
var data = { "machine_id": machine_id }
$.ajax({
type: "POST",
url: "api/machine_information",
@@ -508,7 +513,7 @@ function load_modal_rename_machine(machine_id) {
document.getElementById('modal_title').innerHTML = "Loading..."
document.getElementById('modal_confirm').className = "green btn-flat white-text"
document.getElementById('modal_confirm').innerText = "Rename"
var data = { "id": machine_id }
var data = { "machine_id": machine_id }
$.ajax({
type: "POST",
url: "api/machine_information",
@@ -562,6 +567,8 @@ function load_modal_add_machine() {
$.ajax({
type: "POST",
url: "api/get_users",
data: "{}",
contentType: "application/json",
success: function (response) {
modal_body = document.getElementById('default_add_new_machine_modal');
modal_confirm = document.getElementById('new_machine_modal_confirm');
@@ -613,8 +620,7 @@ function delete_chip(machine_id, chipsData) {
for (let tag in chipsData) {
formattedData[tag] = '"tag:' + chipsData[tag].tag + '"'
}
var tags_list = '{"tags": [' + formattedData + ']}'
var data = { "id": machine_id, "tags_list": tags_list }
var data = { "machine_id": machine_id, "tags": formattedData }
$.ajax({
type: "POST",
@@ -636,8 +642,7 @@ function add_chip(machine_id, chipsData) {
for (let tag in chipsData) {
formattedData[tag] = '"tag:' + chipsData[tag].tag + '"'
}
var tags_list = '{"tags": [' + formattedData + ']}'
var data = { "id": machine_id, "tags_list": tags_list }
var data = { "machine_id": machine_id, "tags": formattedData }
$.ajax({
type: "POST",
@@ -670,18 +675,17 @@ function add_machine() {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.machine) {
window.location.reload()
}
load_modal_generic("error", "Error adding machine", response.message)
return
window.location.reload()
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error adding machine", JSON.parse(xhr.responseText).message)
}
})
}
function rename_machine(machine_id) {
var new_name = document.getElementById('new_name_form').value;
var data = { "id": machine_id, "new_name": new_name };
var data = { "machine_id": machine_id, "new_name": new_name };
// String to test against
var regexIT = /[`!@#$%^&*()_+\=\[\]{};':"\\|,.<>\/?~]/;
@@ -699,24 +703,22 @@ function rename_machine(machine_id) {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
if (response.status == "True") {
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
document.getElementById(machine_id + '-name-container').innerHTML = machine_id + ". " + escapeHTML(new_name)
M.toast({ html: 'Machine ' + machine_id + ' renamed to ' + escapeHTML(new_name) });
} else {
load_modal_generic("error", "Error setting the machine name", "Headscale response: " + JSON.stringify(response.body.message))
}
document.getElementById(machine_id + '-name-container').innerHTML = machine_id + ". " + escapeHTML(new_name)
M.toast({ html: 'Machine ' + machine_id + ' renamed to ' + escapeHTML(new_name) });
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error setting the machine name", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
function move_machine(machine_id) {
new_user = document.getElementById('move-select').value
var data = { "id": machine_id, "new_user": new_user };
var data = { "machine_id": machine_id, "user": new_user };
$.ajax({
type: "POST",
@@ -741,7 +743,7 @@ function move_machine(machine_id) {
}
function delete_machine(machine_id) {
var data = { "id": machine_id };
var data = { "machine_id": machine_id };
$.ajax({
type: "POST",
url: "api/delete_machine",
@@ -756,6 +758,9 @@ function delete_machine(machine_id) {
document.getElementById(machine_id + '-main-collapsible').className = "collapsible popout hide";
M.toast({ html: 'Machine deleted.' });
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error deleting machine", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
@@ -864,6 +869,7 @@ function get_routes() {
async: false,
type: "POST",
url: "api/get_routes",
data: "{}",
contentType: "application/json",
success: function (response) {
console.log("Got all routes.")
@@ -888,8 +894,8 @@ function toggle_failover_route_routespage(routeid, current_state, prefix, route_
var disabledTooltip = "Click to enable"
var enabledTooltip = "Click to disable"
var disableState = "False"
var enableState = "True"
var disableState = false
var enableState = true
var action_taken = "unchanged."
$.ajax({
@@ -1028,25 +1034,24 @@ function rename_user(user_id, old_name) {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.status == "True") {
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Rename the user on the page:
document.getElementById(user_id + '-name-span').innerHTML = escapeHTML(new_name)
// Rename the user on the page:
document.getElementById(user_id + '-name-span').innerHTML = escapeHTML(new_name)
// Set the button to use the NEW name as the OLD name for both buttons
var rename_button_sm = document.getElementById(user_id + '-rename-user-sm')
rename_button_sm.setAttribute('onclick', 'load_modal_rename_user(' + user_id + ', "' + new_name + '")')
var rename_button_lg = document.getElementById(user_id + '-rename-user-lg')
rename_button_lg.setAttribute('onclick', 'load_modal_rename_user(' + user_id + ', "' + new_name + '")')
// Set the button to use the NEW name as the OLD name for both buttons
var rename_button_sm = document.getElementById(user_id + '-rename-user-sm')
rename_button_sm.setAttribute('onclick', 'load_modal_rename_user(' + user_id + ', "' + new_name + '")')
var rename_button_lg = document.getElementById(user_id + '-rename-user-lg')
rename_button_lg.setAttribute('onclick', 'load_modal_rename_user(' + user_id + ', "' + new_name + '")')
// Send the completion toast
M.toast({ html: "User '" + old_name + "' renamed to '" + new_name + "'." })
} else {
load_modal_generic("error", "Error setting user name", "Headscale response: " + JSON.stringify(response.body.message))
}
// Send the completion toast
M.toast({ html: "User '" + old_name + "' renamed to '" + new_name + "'." })
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error setting user name", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
@@ -1059,19 +1064,17 @@ function delete_user(user_id, user_name) {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.status == "True") {
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// When the machine is deleted, hide its collapsible:
document.getElementById(user_id + '-main-collapsible').className = "collapsible popout hide";
// When the machine is deleted, hide its collapsible:
document.getElementById(user_id + '-main-collapsible').className = "collapsible popout hide";
M.toast({ html: 'User deleted.' });
} else {
// We errored. Decipher the error Headscale sent us and display it:
load_modal_generic("error", "Error deleting user", "Headscale response: " + JSON.stringify(response.body.message))
}
M.toast({ html: 'User deleted.' });
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error deleting user", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
@@ -1085,18 +1088,16 @@ function add_user() {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.status == "True") {
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Send the completion toast
M.toast({ html: "User '" + user_name + "' added to Headscale. Refreshing..." })
window.location.reload()
} else {
// We errored. Decipher the error Headscale sent us and display it:
load_modal_generic("error", "Error adding user", "Headscale response: " + JSON.stringify(response.body.message))
}
// Send the completion toast
M.toast({ html: "User '" + user_name + "' added to Headscale. Refreshing..." })
window.location.reload()
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error adding user", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
@@ -1109,7 +1110,7 @@ function add_preauth_key(user_name) {
// If there is no date, error:
if (!date) { load_modal_generic("error", "Invalid Date", "Please enter a valid date"); return }
var data = { "user": user_name, "reusable": reusable, "ephemeral": ephemeral, "expiration": expiration }
var data = { "user": user_name, "reusable": reusable, "ephemeral": ephemeral, "expiration": expiration, "acl_tags": [] }
$.ajax({
type: "POST",
@@ -1117,33 +1118,31 @@ function add_preauth_key(user_name) {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.status == "True") {
// Send the completion toast
M.toast({ html: 'PreAuth key created in user ' + user_name })
// If this is successfull, we should reload the table and close the modal:
var user_data = { "name": user_name }
$.ajax({
type: "POST",
url: "api/build_preauthkey_table",
data: JSON.stringify(user_data),
contentType: "application/json",
success: function (table_data) {
table = document.getElementById(user_name + '-preauth-keys-collection')
table.innerHTML = table_data
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
}
})
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Send the completion toast
M.toast({ html: 'PreAuth key created in user ' + user_name })
// If this is successful, we should reload the table and close the modal:
var user_data = { "user": user_name }
$.ajax({
type: "POST",
url: "api/build_preauthkey_table",
data: JSON.stringify(user_data),
contentType: "application/json",
success: function (table_data) {
table = document.getElementById(user_name + '-preauth-keys-collection')
table.innerHTML = table_data
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
}
})
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
} else {
load_modal_generic("error", "Error adding a pre-auth key", "Headscale response: " + JSON.stringify(response.body.message))
}
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error adding a pre-auth key", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}
@@ -1157,33 +1156,31 @@ function expire_preauth_key(user_name, key) {
data: JSON.stringify(data),
contentType: "application/json",
success: function (response) {
if (response.status == "True") {
// Send the completion toast
M.toast({ html: 'PreAuth expired in ' + user_name })
// If this is successfull, we should reload the table and close the modal:
var user_data = { "name": user_name }
$.ajax({
type: "POST",
url: "api/build_preauthkey_table",
data: JSON.stringify(user_data),
contentType: "application/json",
success: function (table_data) {
table = document.getElementById(user_name + '-preauth-keys-collection')
table.innerHTML = table_data
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
}
})
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// Send the completion toast
M.toast({ html: 'PreAuth expired in ' + user_name })
// If this is successful, we should reload the table and close the modal:
var user_data = { "user": user_name }
$.ajax({
type: "POST",
url: "api/build_preauthkey_table",
data: JSON.stringify(user_data),
contentType: "application/json",
success: function (table_data) {
table = document.getElementById(user_name + '-preauth-keys-collection')
table.innerHTML = table_data
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
}
})
// Get the modal element and close it
modal_element = document.getElementById('card_modal')
M.Modal.getInstance(modal_element).close()
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
} else {
load_modal_generic("error", "Error expiring a pre-auth key", "Headscale response: " + JSON.stringify(response.body.message))
}
// The tooltips need to be re-initialized afterwards:
M.Tooltip.init(document.querySelectorAll('.tooltipped'))
},
error: function (xhr, textStatus, errorThrown) {
load_modal_generic("error", "Error expiring a pre-auth key", "Headscale response: " + JSON.parse(xhr.responseText).message)
}
})
}

View File

@@ -23,7 +23,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<!-- CSS and Icons -->
<link href="static/css/materialize.min.css" type="text/css" rel="stylesheet"/>
<link href="static/css/materialize.min.css" type="text/css" rel="stylesheet"/>
<link href="static/css/overrides.css" type="text/css" rel="stylesheet"/>
<style>
/* fallback */
@@ -34,7 +34,7 @@
/* src: url(https://fonts.gstatic.com/s/materialicons/v139/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2'); */
src: url(static/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
@@ -54,7 +54,7 @@
</head>
<body>
<div class="container"><br><br><br>
{{ ERROR_MESSAGE }}
{{ error_message }}
<br>
<center>Click <a href="overview">here</a> to retry.</center>
</div>
@@ -70,7 +70,7 @@
</div>
</body>
<script type="text/javascript" src="static/js/jquery-2.2.4.min.js"></script>
<script type="text/javascript" src="static/js/materialize.min.js"></script>
<script type="text/javascript" src="static/js/materialize.min.js"></script>
<script>M.AutoInit();</script>
<script type="text/javascript" src="static/js/custom.js"></script>
</html>
</html>

View File

@@ -5,9 +5,9 @@
{% block title %} {{ page }} {% endblock %}
{% block header %} {{ page }} {% endblock %}
{% block OIDC_NAV_DROPDOWN %} {{ OIDC_NAV_DROPDOWN}} {% endblock %}
{% block OIDC_NAV_MOBILE %} {{ OIDC_NAV_MOBILE }} {% endblock %}
{% block INPAGE_SEARCH%} {{ INPAGE_SEARCH }} {% endblock %}
{% block oidc_nav_dropdown %} {{ oidc_nav_dropdown}} {% endblock %}
{% block oidc_nav_mobile %} {{ oidc_nav_mobile }} {% endblock %}
{% block inpage_search%} {{ inpage_search }} {% endblock %}
{% block content %}
@@ -84,12 +84,12 @@
</div>
<!-- FAB for adding new machines -->
<div class="fixed-action-btn">
<a href="#new_machine_card_modal"
onclick='load_modal_add_machine()'
class="modal-trigger waves-effect waves-light btn-floating btn-large tooltipped {{ COLOR_BTN }}"
<a href="#new_machine_card_modal"
onclick='load_modal_add_machine()'
class="modal-trigger waves-effect waves-light btn-floating btn-large tooltipped {{ color_btn }}"
data-tooltip="Add a new machine to the Tailnet" data-position="left">
<i class="large material-icons">add</i>
</a>
</div>
{% endblock %}
{% endblock %}

View File

@@ -5,9 +5,9 @@
{% block title %} {{ page }} {% endblock %}
{% block header %} {{ page }} {% endblock %}
{% block OIDC_NAV_DROPDOWN %} {{ OIDC_NAV_DROPDOWN}} {% endblock %}
{% block OIDC_NAV_MOBILE %} {{ OIDC_NAV_MOBILE }} {% endblock %}
{% block oidc_nav_dropdown %} {{ oidc_nav_dropdown}} {% endblock %}
{% block oidc_nav_mobile %} {{ oidc_nav_mobile }} {% endblock %}
{% block content %}
{{ render_page }}
{% endblock %}
{% endblock %}

View File

@@ -5,8 +5,8 @@
{% block title %} {{ page }} {% endblock %}
{% block header %} {{ page }} {% endblock %}
{% block OIDC_NAV_DROPDOWN %} {{ OIDC_NAV_DROPDOWN}} {% endblock %}
{% block OIDC_NAV_MOBILE %} {{ OIDC_NAV_MOBILE }} {% endblock %}
{% block oidc_nav_dropdown %} {{ oidc_nav_dropdown}} {% endblock %}
{% block oidc_nav_mobile %} {{ oidc_nav_mobile }} {% endblock %}
{% block content %}
<div class="row"><br>
@@ -26,5 +26,5 @@
<a href="#!" class="modal-close btn-flat">Cancel</a>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -3,8 +3,8 @@
{% set settings_active = "active" %}
{% block title %} {{ page }} {% endblock %}
{% block header %} {{ page }} {% endblock %}
{% block OIDC_NAV_DROPDOWN %} {{ OIDC_NAV_DROPDOWN}} {% endblock %}
{% block OIDC_NAV_MOBILE %} {{ OIDC_NAV_MOBILE }} {% endblock %}
{% block oidc_nav_dropdown %} {{ oidc_nav_dropdown}} {% endblock %}
{% block oidc_nav_mobile %} {{ oidc_nav_mobile }} {% endblock %}
{% block content %}
<div class="row"><br>
@@ -17,7 +17,7 @@
<i class="material-icons prefix">vpn_key</i>
<input id="api_key" type="password">
<label for="api_key">API Key</label>
</div>
</div>
</div>
<div class="card-action">
<a href="#test_modal" class="modal-trigger" onclick="save_key()">Save</a>
@@ -39,9 +39,9 @@
<tr><td>Compatibility</td><td><a href="https://github.com/juanfont/headscale">Headscale {{ HS_VERSION }}</a></td></tr>
<tr><td>App Version</td><td>{{ APP_VERSION }}</td></tr>
<tr><td>Build Date</td><td> {{ BUILD_DATE }}</td></tr>
<tr><td>Git Commit</td><td>{{ GIT_COMMIT }}</td></tr>
<tr><td>Git Commit</td><td><a href='{{ GIT_REPO_URL }}/commit/{{ GIT_COMMIT }}'>{{ GIT_COMMIT[:7] }}</a></td></tr>
<tr><td>Git Branch</td><td>{{ GIT_BRANCH }}</td></tr>
<tr><td>Source Code</td><td><a href="https://github.com/iFargle/headscale-webui">Github</a></td></tr>
<tr><td>Source Code</td><td><a href="{{ GIT_REPO_URL }}/tree/{{ GIT_BRANCH }}">Github</a></td></tr>
</tbody>
</table>
</div>
@@ -54,9 +54,9 @@
<div class="modal-content">
<h4>Instructions</h4>
<ul class="browser-default">
<li>To generate your API key, run the command <a class="{{ COLOR_BTN }} white-text">headscale apikeys create</a> on your control server. Once you generate your first key, this UI will automatically renew the key near expiration.</li>
<li>The Headscale server is configured via the <a class="{{ COLOR_BTN }} white-text">HS_SERVER</a> environment variable in Docker. Current server: <a class="{{ COLOR_BTN }} white-text"> {{url}} </a></li>
<li>You must configure an encryption key via the <a class="{{ COLOR_BTN }} white-text">KEY</a> environment variable in Docker. One can be generated with the command <a class="{{ COLOR_BTN }} white-text">openssl rand -base64 32</a></li>
<li>To generate your API key, run the command <a class="{{ color_btn }} white-text">headscale apikeys create</a> on your control server. Once you generate your first key, this UI will automatically renew the key near expiration.</li>
<li>The Headscale server is configured via the <a class="{{ color_btn }} white-text">HS_SERVER</a> environment variable in Docker. Current server: <a class="{{ color_btn }} white-text"> {{url}} </a></li>
<li>You must configure an encryption key via the <a class="{{ color_btn }} white-text">KEY</a> environment variable in Docker. One can be generated with the command <a class="{{ color_btn }} white-text">openssl rand -base64 32</a></li>
</ul>
</div>
<div class="modal-footer">
@@ -81,4 +81,4 @@
<a href="#!" class="modal-close btn-flat">Close</a>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -23,7 +23,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<!-- CSS and Icons -->
<link href="static/css/materialize.min.css" type="text/css" rel="stylesheet"/>
<link href="static/css/materialize.min.css" type="text/css" rel="stylesheet"/>
<link href="static/css/overrides.css" type="text/css" rel="stylesheet"/>
<style>
/* fallback */
@@ -33,7 +33,7 @@
font-weight: 400;
src: url(static/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2) format('woff2');
}
.material-icons {
font-family: 'Material Icons';
font-weight: normal;
@@ -54,7 +54,7 @@
</head>
<body>
<nav>
<div class="nav-wrapper {{ COLOR_NAV }}">
<div class="nav-wrapper {{ color_nav }}">
<div id="nav-search" class="nav-search hidden">
<form>
<div class="input-field">
@@ -76,7 +76,7 @@
</a>
<a href="#!" data-target="mobile" class="sidenav-trigger"><i class="material-icons">menu</i></a>
<ul class="right hide-on-med-and-down">
{% block INPAGE_SEARCH %}{% endblock %}
{% block inpage_search %}{% endblock %}
<li role="menu-item" class="tooltipped {{ overview_active }}" data-position="bottom" data-tooltip="Overview">
<a href="overview"><i class="material-icons">map</i></a>
</li>
@@ -92,7 +92,7 @@
<li role="menu-item" class="tooltipped {{ settings_active }}" data-position="bottom" data-tooltip="Settings">
<a href="settings"><i class="material-icons">settings</i></a>
</li>
{% block OIDC_NAV_DROPDOWN %}{% endblock %}
{% block oidc_nav_dropdown %}{% endblock %}
</ul>
</div>
</div>
@@ -103,7 +103,7 @@
<li><a href="machines"><i class="material-icons left">devices</i>Machines</a></li>
<li><a href="users"><i class="material-icons left">people</i>Users</a></li>
<li><a href="settings"><i class="material-icons left">settings</i>Settings</a></li>
{% block OIDC_NAV_MOBILE %}{% endblock %}
{% block oidc_nav_mobile %}{% endblock %}
</ul>
<div class="container">
{% block content %} {% endblock %}
@@ -121,14 +121,14 @@
</body>
<!-- JavaScript / jQuery -->
<script type="text/javascript" src="static/js/jquery-2.2.4.min.js"></script>
<script type="text/javascript" src="static/js/materialize.min.js"></script>
<script type="text/javascript" src="static/js/materialize.min.js"></script>
<script>M.AutoInit();</script>
<!-- Prevent the collapsibles on the Users and Machines pages from collapsing when another element is clicked -->
<script>
<script>
var elem = document.querySelector('.collapsible.expandable');
var instance = M.Collapsible.init(elem, {
accordion: false
});
</script>
<script type="text/javascript" src="static/js/custom.js"></script>
</html>
</html>

View File

@@ -5,9 +5,9 @@
{% block title %} {{ page }} {% endblock %}
{% block header %} {{ page }} {% endblock %}
{% block OIDC_NAV_DROPDOWN %} {{ OIDC_NAV_DROPDOWN}} {% endblock %}
{% block OIDC_NAV_MOBILE %} {{ OIDC_NAV_MOBILE }} {% endblock %}
{% block INPAGE_SEARCH%} {{ INPAGE_SEARCH }} {% endblock %}
{% block oidc_nav_dropdown %} {{ oidc_nav_dropdown}} {% endblock %}
{% block oidc_nav_mobile %} {{ oidc_nav_mobile }} {% endblock %}
{% block inpage_search%} {{ inpage_search }} {% endblock %}
{% block content %}
<div class="row"><br>
@@ -49,10 +49,10 @@
</div>
<!-- FAB for adding new users -->
<div class="fixed-action-btn">
<a href="#new_user_card_modal"
class="modal-trigger waves-effect waves-light btn-floating btn-large red tooltipped {{ COLOR_BTN }}"
<a href="#new_user_card_modal"
class="modal-trigger waves-effect waves-light btn-floating btn-large red tooltipped {{ color_btn }}"
data-tooltip="Add a new user to the Tailnet" data-position="left">
<i class="large material-icons">add</i>
</a>
</div>
{% endblock %}
{% endblock %}