mirror of
https://github.com/iFargle/headscale-webui.git
synced 2026-03-24 08:08:31 +01:00
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:
241
auth.py
Normal file
241
auth.py
Normal 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
527
config.py
Normal 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"
|
||||
565
headscale.py
565
headscale.py
@@ -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
420
helper.py
@@ -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
1131
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
1797
renderer.py
1797
renderer.py
File diff suppressed because it is too large
Load Diff
903
server.py
903
server.py
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user