Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _env_value(name: str, default: str | None) -> str | None:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
value = value.strip()
|
||||
return value or default
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _secret_value(name: str, file_name: str) -> str | None:
|
||||
value = _env_value(name, None)
|
||||
if value:
|
||||
return value
|
||||
path = _env_value(file_name, None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, encoding="utf-8") as secret_file:
|
||||
return secret_file.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _env_list(name: str, default: list[str] | None = None) -> list[str]:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return list(default or [])
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
app_title: str = "Unraid AppOps Gateway"
|
||||
bind_host: str = "0.0.0.0"
|
||||
port: int = 1216
|
||||
db_path: str = "/data/appops.db"
|
||||
data_dir: str = "/data"
|
||||
environment: str = "development"
|
||||
log_level: str = "info"
|
||||
static_dir: str = "dist"
|
||||
frontend_origin: str = "http://localhost:5173"
|
||||
voice_token: str | None = None
|
||||
voice_token_file: str | None = None
|
||||
operator_token: str | None = None
|
||||
operator_token_file: str | None = None
|
||||
operator_auth_required: bool = False
|
||||
operator_proxy_auth_enabled: bool = False
|
||||
operator_proxy_trusted_networks: list[str] | None = None
|
||||
operator_proxy_users: list[str] | None = None
|
||||
operator_proxy_groups: list[str] | None = None
|
||||
deployment_mode: str = "private"
|
||||
verify_tls: bool = True
|
||||
secret_key: str | None = None
|
||||
secret_key_file: str | None = None
|
||||
secret_key_source: str = "missing"
|
||||
secret_rotation_enabled: bool = False
|
||||
discovery_interval_seconds: int = 0
|
||||
discovery_history_runs: int = 0
|
||||
notification_interval_seconds: int = 0
|
||||
connector_health_interval_seconds: int = 0
|
||||
observability_persist_interval_seconds: int = 0
|
||||
restore_drill_interval_seconds: int = 0
|
||||
mcp_canary_interval_seconds: int = 0
|
||||
backup_retention: int = 20
|
||||
mutation_rate_limit_per_minute: int = 120
|
||||
mcp_enabled: bool = False
|
||||
mcp_token: str | None = None
|
||||
mcp_token_file: str | None = None
|
||||
mcp_allowed_hosts: list[str] | None = None
|
||||
n8n_enabled: bool = False
|
||||
n8n_token: str | None = None
|
||||
n8n_token_file: str | None = None
|
||||
|
||||
apis: dict[str, str | bool | None] = None # populated in __post_init__
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.app_title = _env_value("APP_OPS_APP_TITLE", self.app_title) or self.app_title
|
||||
self.bind_host = _env_value("APPOPS_BIND_HOST", self.bind_host) or self.bind_host
|
||||
self.port = int(_env_value("APPOPS_PORT", str(self.port)) or str(self.port))
|
||||
self.db_path = _env_value("APPOPS_DB_PATH", self.db_path) or self.db_path
|
||||
self.data_dir = _env_value("APPOPS_DATA_DIR", self.data_dir) or self.data_dir
|
||||
self.environment = _env_value("APPOPS_ENV", self.environment) or self.environment
|
||||
default_discovery_interval = 120 if self.environment == "production" else self.discovery_interval_seconds
|
||||
self.discovery_interval_seconds = max(0, int(_env_value("APPOPS_DISCOVERY_INTERVAL_SECONDS", str(default_discovery_interval)) or "0"))
|
||||
default_discovery_history = 48 if self.environment == "production" else self.discovery_history_runs
|
||||
self.discovery_history_runs = max(0, int(_env_value("APPOPS_DISCOVERY_HISTORY_RUNS", str(default_discovery_history)) or "0"))
|
||||
production_default = self.environment == "production"
|
||||
self.notification_interval_seconds = max(0, int(_env_value("APPOPS_NOTIFICATION_INTERVAL_SECONDS", "300" if production_default else str(self.notification_interval_seconds)) or "0"))
|
||||
self.connector_health_interval_seconds = max(0, int(_env_value("APPOPS_CONNECTOR_HEALTH_INTERVAL_SECONDS", "300" if production_default else str(self.connector_health_interval_seconds)) or "0"))
|
||||
self.observability_persist_interval_seconds = max(0, int(_env_value("APPOPS_OBSERVABILITY_PERSIST_INTERVAL_SECONDS", "60" if production_default else str(self.observability_persist_interval_seconds)) or "0"))
|
||||
self.restore_drill_interval_seconds = max(0, int(_env_value("APPOPS_RESTORE_DRILL_INTERVAL_SECONDS", "86400" if production_default else str(self.restore_drill_interval_seconds)) or "0"))
|
||||
self.mcp_canary_interval_seconds = max(0, int(_env_value("APPOPS_MCP_CANARY_INTERVAL_SECONDS", "300" if production_default else str(self.mcp_canary_interval_seconds)) or "0"))
|
||||
self.backup_retention = max(1, min(200, int(_env_value("APPOPS_BACKUP_RETENTION", str(self.backup_retention)) or "20")))
|
||||
self.mutation_rate_limit_per_minute = max(10, min(1000, int(_env_value("APPOPS_MUTATION_RATE_LIMIT", str(self.mutation_rate_limit_per_minute)) or "120")))
|
||||
self.mcp_enabled = _env_bool("APPOPS_ENABLE_MCP", self.mcp_enabled)
|
||||
self.mcp_token = _env_value("APPOPS_MCP_TOKEN", self.mcp_token)
|
||||
self.mcp_token_file = _env_value("APPOPS_MCP_TOKEN_FILE", self.mcp_token_file)
|
||||
if not self.mcp_token and self.mcp_token_file:
|
||||
try:
|
||||
with open(self.mcp_token_file, encoding="utf-8") as token_file:
|
||||
self.mcp_token = token_file.read().strip() or None
|
||||
except OSError:
|
||||
self.mcp_token = None
|
||||
allowed_hosts = _env_value("APPOPS_MCP_ALLOWED_HOSTS", "localhost:*,127.0.0.1:*,198.18.10.150:*") or ""
|
||||
self.mcp_allowed_hosts = [item.strip() for item in allowed_hosts.split(",") if item.strip()]
|
||||
self.n8n_enabled = _env_bool("APPOPS_ENABLE_N8N", self.n8n_enabled)
|
||||
self.n8n_token = _env_value("APPOPS_N8N_TOKEN", self.n8n_token)
|
||||
self.n8n_token_file = _env_value("APPOPS_N8N_TOKEN_FILE", self.n8n_token_file)
|
||||
if not self.n8n_token and self.n8n_token_file:
|
||||
try:
|
||||
with open(self.n8n_token_file, encoding="utf-8") as token_file:
|
||||
self.n8n_token = token_file.read().strip() or None
|
||||
except OSError:
|
||||
self.n8n_token = None
|
||||
self.log_level = (_env_value("APPOPS_LOG_LEVEL", self.log_level) or self.log_level).lower()
|
||||
self.static_dir = _env_value("APPOPS_STATIC_DIR", self.static_dir) or self.static_dir
|
||||
self.frontend_origin = _env_value("APPOPS_FRONTEND_ORIGIN", None) or (
|
||||
f"http://localhost:{self.port}" if self.environment == "production" else self.frontend_origin
|
||||
)
|
||||
self.voice_token_file = _env_value("APP_OPS_VOICE_TOKEN_FILE", self.voice_token_file)
|
||||
self.voice_token = _secret_value("APP_OPS_VOICE_TOKEN", "APP_OPS_VOICE_TOKEN_FILE") or self.voice_token
|
||||
self.operator_token = _env_value("APPOPS_OPERATOR_TOKEN", self.operator_token)
|
||||
self.operator_token_file = _env_value("APPOPS_OPERATOR_TOKEN_FILE", self.operator_token_file)
|
||||
if not self.operator_token and self.operator_token_file:
|
||||
try:
|
||||
with open(self.operator_token_file, encoding="utf-8") as token_file:
|
||||
self.operator_token = token_file.read().strip() or None
|
||||
except OSError:
|
||||
self.operator_token = None
|
||||
self.operator_auth_required = _env_bool("APPOPS_REQUIRE_OPERATOR_AUTH", self.operator_auth_required)
|
||||
self.operator_proxy_auth_enabled = _env_bool("APPOPS_OPERATOR_PROXY_AUTH", self.operator_proxy_auth_enabled)
|
||||
self.operator_proxy_trusted_networks = _env_list("APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS", self.operator_proxy_trusted_networks)
|
||||
self.operator_proxy_users = _env_list("APPOPS_OPERATOR_PROXY_USERS", self.operator_proxy_users)
|
||||
self.operator_proxy_groups = _env_list("APPOPS_OPERATOR_PROXY_GROUPS", self.operator_proxy_groups)
|
||||
self.deployment_mode = (_env_value("APPOPS_DEPLOYMENT_MODE", self.deployment_mode) or self.deployment_mode).lower()
|
||||
self.verify_tls = _env_bool("APPOPS_VERIFY_TLS", self.verify_tls)
|
||||
self.secret_key_file = _env_value("APPOPS_SECRET_KEY_FILE", self.secret_key_file)
|
||||
self.secret_key = _env_value("APPOPS_SECRET_KEY", self.secret_key)
|
||||
self.secret_key_source = "env" if self.secret_key else "missing"
|
||||
if not self.secret_key and self.secret_key_file:
|
||||
try:
|
||||
with open(self.secret_key_file, encoding="utf-8") as key_file:
|
||||
self.secret_key = key_file.read().strip() or None
|
||||
self.secret_key_source = "file" if self.secret_key else "missing"
|
||||
except OSError:
|
||||
self.secret_key = None
|
||||
self.secret_key_source = "file_unreadable"
|
||||
self.secret_rotation_enabled = _env_bool("APPOPS_SECRET_ROTATION_ENABLED", self.secret_rotation_enabled)
|
||||
gitea_admin_token = _secret_value("GITEA_ADMIN_TOKEN", "GITEA_ADMIN_TOKEN_FILE")
|
||||
self.apis = {
|
||||
"PORTAINER_URL": _env_value("PORTAINER_URL", None),
|
||||
"PORTAINER_API_KEY": _env_value("PORTAINER_API_KEY", None),
|
||||
"PORTAINER_ENDPOINT_ID": _env_value("PORTAINER_ENDPOINT_ID", None),
|
||||
"PORTAINER_VERIFY_TLS": _env_bool("PORTAINER_VERIFY_TLS", self.verify_tls),
|
||||
"UNRAID_URL": _env_value("UNRAID_URL", None),
|
||||
"UNRAID_API_KEY": _env_value("UNRAID_API_KEY", None),
|
||||
"UNRAID_VERIFY_TLS": _env_bool("UNRAID_VERIFY_TLS", self.verify_tls),
|
||||
"GITEA_URL": _env_value("GITEA_URL", None),
|
||||
"GITEA_ADMIN_TOKEN": gitea_admin_token,
|
||||
"GITEA_VERIFY_TLS": _env_bool("GITEA_VERIFY_TLS", self.verify_tls),
|
||||
"GITEA_TIMEOUT_SECONDS": _env_value("GITEA_TIMEOUT_SECONDS", "8"),
|
||||
"GITEA_RUNNER_TARGETS_JSON": _env_value(
|
||||
"GITEA_RUNNER_TARGETS_JSON",
|
||||
'{}',
|
||||
),
|
||||
# Deployment Control Plane V1 uses the private broker's dedicated
|
||||
# typed endpoint. The HMAC material is always resolved from the
|
||||
# encrypted Secret store by reference, never from this environment
|
||||
# preview or a deployment plan.
|
||||
"DEPLOYMENT_BROKER_URL": _env_value("DEPLOYMENT_BROKER_URL", None),
|
||||
"DEPLOYMENT_BROKER_VERIFY_TLS": _env_bool(
|
||||
"DEPLOYMENT_BROKER_VERIFY_TLS", self.verify_tls
|
||||
),
|
||||
"DEPLOYMENT_BROKER_CA_FILE": _env_value(
|
||||
"DEPLOYMENT_BROKER_CA_FILE", None
|
||||
),
|
||||
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS": _env_value(
|
||||
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS", "10"
|
||||
),
|
||||
"SONARR_URL": _env_value("SONARR_URL", None),
|
||||
"SONARR_API_KEY": _env_value("SONARR_API_KEY", None),
|
||||
"SONARR_DEFAULT_ROOT_FOLDER": _env_value("SONARR_DEFAULT_ROOT_FOLDER", None),
|
||||
"SONARR_DEFAULT_QUALITY_PROFILE": _env_value("SONARR_DEFAULT_QUALITY_PROFILE", None),
|
||||
"SONARR_SEARCH_ON_ADD": _env_bool("SONARR_SEARCH_ON_ADD", True),
|
||||
"RADARR_URL": _env_value("RADARR_URL", None),
|
||||
"RADARR_API_KEY": _env_value("RADARR_API_KEY", None),
|
||||
"RADARR_DEFAULT_ROOT_FOLDER": _env_value("RADARR_DEFAULT_ROOT_FOLDER", None),
|
||||
"RADARR_DEFAULT_QUALITY_PROFILE": _env_value("RADARR_DEFAULT_QUALITY_PROFILE", None),
|
||||
"RADARR_DEFAULT_MINIMUM_AVAILABILITY": _env_value("RADARR_DEFAULT_MINIMUM_AVAILABILITY", "released"),
|
||||
"RADARR_SEARCH_ON_ADD": _env_bool("RADARR_SEARCH_ON_ADD", True),
|
||||
"RADARR_VERIFY_TLS": _env_bool("RADARR_VERIFY_TLS", self.verify_tls),
|
||||
"RADARR_TIMEOUT_SECONDS": _env_value("RADARR_TIMEOUT_SECONDS", "10"),
|
||||
"PROWLARR_URL": _env_value("PROWLARR_URL", None),
|
||||
"PROWLARR_API_KEY": _env_value("PROWLARR_API_KEY", None),
|
||||
"PROWLARR_VERIFY_TLS": _env_bool("PROWLARR_VERIFY_TLS", self.verify_tls),
|
||||
"PROWLARR_TIMEOUT_SECONDS": _env_value("PROWLARR_TIMEOUT_SECONDS", "10"),
|
||||
"DELUGE_URL": _env_value("DELUGE_URL", None),
|
||||
"DELUGE_PASSWORD": _env_value("DELUGE_PASSWORD", None),
|
||||
"DELUGE_VERIFY_TLS": _env_bool("DELUGE_VERIFY_TLS", self.verify_tls),
|
||||
"DELUGE_TIMEOUT_SECONDS": _env_value("DELUGE_TIMEOUT_SECONDS", "10"),
|
||||
"DELUGE_DEFAULT_HOST_ID": _env_value("DELUGE_DEFAULT_HOST_ID", None),
|
||||
"DELUGE_WEB_PATH": _env_value("DELUGE_WEB_PATH", "/json"),
|
||||
"PLEX_URL": _env_value("PLEX_URL", None),
|
||||
"PLEX_TOKEN": _env_value("PLEX_TOKEN", None),
|
||||
"PLEX_VERIFY_TLS": _env_bool("PLEX_VERIFY_TLS", self.verify_tls),
|
||||
"PLEX_TIMEOUT_SECONDS": _env_value("PLEX_TIMEOUT_SECONDS", "10"),
|
||||
"PLEX_DEFAULT_LIBRARY_ID": _env_value("PLEX_DEFAULT_LIBRARY_ID", None),
|
||||
"PLEX_MACHINE_IDENTIFIER": _env_value("PLEX_MACHINE_IDENTIFIER", None),
|
||||
"TAUTULLI_URL": _env_value("TAUTULLI_URL", None),
|
||||
"TAUTULLI_API_KEY": _env_value("TAUTULLI_API_KEY", None),
|
||||
"TAUTULLI_VERIFY_TLS": _env_bool("TAUTULLI_VERIFY_TLS", self.verify_tls),
|
||||
"TAUTULLI_TIMEOUT_SECONDS": _env_value("TAUTULLI_TIMEOUT_SECONDS", "10"),
|
||||
"NPM_URL": _env_value("NPM_URL", None),
|
||||
"NPM_IDENTITY": _env_value("NPM_IDENTITY", None),
|
||||
"NPM_SECRET": _env_value("NPM_SECRET", None),
|
||||
"NPM_VERIFY_TLS": _env_bool("NPM_VERIFY_TLS", self.verify_tls),
|
||||
"NPM_TIMEOUT_SECONDS": _env_value("NPM_TIMEOUT_SECONDS", "10"),
|
||||
"NPM_LETSENCRYPT_EMAIL": _env_value("NPM_LETSENCRYPT_EMAIL", None),
|
||||
"AUTHENTIK_URL": _env_value("AUTHENTIK_URL", None),
|
||||
"AUTHENTIK_TOKEN": _env_value("AUTHENTIK_TOKEN", None),
|
||||
"AUTHENTIK_VERIFY_TLS": _env_bool("AUTHENTIK_VERIFY_TLS", self.verify_tls),
|
||||
"AUTHENTIK_TIMEOUT_SECONDS": _env_value("AUTHENTIK_TIMEOUT_SECONDS", "10"),
|
||||
"AUTHENTIK_OUTPOST_URL": _env_value("AUTHENTIK_OUTPOST_URL", None),
|
||||
"AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": _env_bool("AUTHENTIK_EXPECT_EMBEDDED_OUTPOST", True),
|
||||
# OIDC provisioning never guesses tenant-wide flow/mapping IDs.
|
||||
# Operators bind these once to existing, reviewed Authentik objects.
|
||||
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": _env_value("AUTHENTIK_OIDC_AUTHORIZATION_FLOW", None),
|
||||
"AUTHENTIK_OIDC_INVALIDATION_FLOW": _env_value("AUTHENTIK_OIDC_INVALIDATION_FLOW", None),
|
||||
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": _env_value("AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING", None),
|
||||
# The reviewed group mapping must emit this exact claim. The
|
||||
# enrollment schema only accepts its bounded role contract.
|
||||
"AUTHENTIK_OIDC_ROLE_CLAIM": _env_value("AUTHENTIK_OIDC_ROLE_CLAIM", None),
|
||||
"JELLYFIN_URL": _env_value("JELLYFIN_URL", None),
|
||||
"JELLYFIN_API_KEY": _env_value("JELLYFIN_API_KEY", None),
|
||||
"WIDEFROG_URL": _env_value("WIDEFROG_URL", None),
|
||||
"WIDEFROG_API_KEY": _env_value("WIDEFROG_API_KEY", None),
|
||||
"WIDEFROG_VERIFY_TLS": _env_bool("WIDEFROG_VERIFY_TLS", self.verify_tls),
|
||||
"WIDEFROG_TIMEOUT_SECONDS": _env_value("WIDEFROG_TIMEOUT_SECONDS", "10"),
|
||||
"HOME_ASSISTANT_URL": _env_value("HOME_ASSISTANT_URL", None),
|
||||
"HOME_ASSISTANT_TOKEN": _env_value("HOME_ASSISTANT_TOKEN", None),
|
||||
"HOME_ASSISTANT_VERIFY_TLS": _env_bool("HOME_ASSISTANT_VERIFY_TLS", self.verify_tls),
|
||||
"HOME_ASSISTANT_TIMEOUT_SECONDS": _env_value("HOME_ASSISTANT_TIMEOUT_SECONDS", "10"),
|
||||
"HOME_ASSISTANT_NOTIFY_DOMAIN": _env_value("HOME_ASSISTANT_NOTIFY_DOMAIN", "notify"),
|
||||
"HOME_ASSISTANT_NOTIFY_SERVICE": _env_value("HOME_ASSISTANT_NOTIFY_SERVICE", "notify"),
|
||||
"HOME_ASSISTANT_NOTIFY_TARGET": _env_value("HOME_ASSISTANT_NOTIFY_TARGET", None),
|
||||
"HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": _env_value("HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX", "AppOps"),
|
||||
"HOME_ASSISTANT_DRY_RUN": _env_bool("HOME_ASSISTANT_DRY_RUN", False),
|
||||
"APPOPS_VERIFY_TLS": self.verify_tls,
|
||||
}
|
||||
|
||||
def redact_env_preview(self) -> dict[str, Any]:
|
||||
return {
|
||||
"APPOPS_BIND_HOST": self.bind_host,
|
||||
"APPOPS_PORT": self.port,
|
||||
"APPOPS_DB_PATH": self.db_path,
|
||||
"APPOPS_DATA_DIR": self.data_dir,
|
||||
"APPOPS_ENV": self.environment,
|
||||
"APPOPS_LOG_LEVEL": self.log_level,
|
||||
"APPOPS_STATIC_DIR": self.static_dir,
|
||||
"APPOPS_FRONTEND_ORIGIN": self.frontend_origin,
|
||||
"voice_token": bool(self.voice_token),
|
||||
"operator_token": bool(self.operator_token),
|
||||
"APPOPS_OPERATOR_TOKEN_FILE": bool(self.operator_token_file),
|
||||
"APPOPS_REQUIRE_OPERATOR_AUTH": self.operator_auth_required,
|
||||
"APPOPS_OPERATOR_PROXY_AUTH": self.operator_proxy_auth_enabled,
|
||||
"APPOPS_OPERATOR_PROXY_TRUSTED_NETWORKS_CONFIGURED": bool(self.operator_proxy_trusted_networks),
|
||||
"APPOPS_OPERATOR_PROXY_USER_COUNT": len(self.operator_proxy_users or []),
|
||||
"APPOPS_OPERATOR_PROXY_GROUP_COUNT": len(self.operator_proxy_groups or []),
|
||||
"APPOPS_DEPLOYMENT_MODE": self.deployment_mode,
|
||||
"APPOPS_BACKUP_RETENTION": self.backup_retention,
|
||||
"APPOPS_ENABLE_MCP": self.mcp_enabled,
|
||||
"APPOPS_MCP_TOKEN_FILE": bool(self.mcp_token_file),
|
||||
"APPOPS_MCP_TOKEN_CONFIGURED": bool(self.mcp_token),
|
||||
"APPOPS_MCP_ALLOWED_HOSTS": list(self.mcp_allowed_hosts or []),
|
||||
"APPOPS_ENABLE_N8N": self.n8n_enabled,
|
||||
"APPOPS_N8N_TOKEN_FILE": bool(self.n8n_token_file),
|
||||
"APPOPS_N8N_TOKEN_CONFIGURED": bool(self.n8n_token),
|
||||
"APPOPS_VERIFY_TLS": self.verify_tls,
|
||||
"APPOPS_SECRET_KEY": bool(self.secret_key),
|
||||
"APPOPS_SECRET_KEY_FILE": bool(self.secret_key_file),
|
||||
"APPOPS_SECRET_KEY_SOURCE": self.secret_key_source,
|
||||
"APPOPS_SECRET_ROTATION_ENABLED": self.secret_rotation_enabled,
|
||||
"PORTAINER_URL": bool(self.apis["PORTAINER_URL"]),
|
||||
"PORTAINER_VERIFY_TLS": bool(self.apis["PORTAINER_VERIFY_TLS"]),
|
||||
"UNRAID_URL": bool(self.apis["UNRAID_URL"]),
|
||||
"UNRAID_VERIFY_TLS": bool(self.apis["UNRAID_VERIFY_TLS"]),
|
||||
"GITEA_URL": bool(self.apis["GITEA_URL"]),
|
||||
"GITEA_ADMIN_TOKEN_CONFIGURED": bool(self.apis["GITEA_ADMIN_TOKEN"]),
|
||||
"GITEA_VERIFY_TLS": bool(self.apis["GITEA_VERIFY_TLS"]),
|
||||
"DEPLOYMENT_BROKER_URL": bool(self.apis["DEPLOYMENT_BROKER_URL"]),
|
||||
"DEPLOYMENT_BROKER_VERIFY_TLS": bool(self.apis["DEPLOYMENT_BROKER_VERIFY_TLS"]),
|
||||
"DEPLOYMENT_BROKER_CA_FILE": bool(self.apis["DEPLOYMENT_BROKER_CA_FILE"]),
|
||||
"DEPLOYMENT_BROKER_TIMEOUT_SECONDS": bool(self.apis["DEPLOYMENT_BROKER_TIMEOUT_SECONDS"]),
|
||||
"SONARR_URL": bool(self.apis["SONARR_URL"]),
|
||||
"SONARR_DEFAULT_ROOT_FOLDER": bool(self.apis["SONARR_DEFAULT_ROOT_FOLDER"]),
|
||||
"SONARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["SONARR_DEFAULT_QUALITY_PROFILE"]),
|
||||
"SONARR_SEARCH_ON_ADD": bool(self.apis["SONARR_SEARCH_ON_ADD"]),
|
||||
"RADARR_URL": bool(self.apis["RADARR_URL"]),
|
||||
"RADARR_DEFAULT_ROOT_FOLDER": bool(self.apis["RADARR_DEFAULT_ROOT_FOLDER"]),
|
||||
"RADARR_DEFAULT_QUALITY_PROFILE": bool(self.apis["RADARR_DEFAULT_QUALITY_PROFILE"]),
|
||||
"RADARR_DEFAULT_MINIMUM_AVAILABILITY": bool(self.apis["RADARR_DEFAULT_MINIMUM_AVAILABILITY"]),
|
||||
"RADARR_SEARCH_ON_ADD": bool(self.apis["RADARR_SEARCH_ON_ADD"]),
|
||||
"RADARR_VERIFY_TLS": bool(self.apis["RADARR_VERIFY_TLS"]),
|
||||
"RADARR_TIMEOUT_SECONDS": bool(self.apis["RADARR_TIMEOUT_SECONDS"]),
|
||||
"PROWLARR_URL": bool(self.apis["PROWLARR_URL"]),
|
||||
"PROWLARR_VERIFY_TLS": bool(self.apis["PROWLARR_VERIFY_TLS"]),
|
||||
"PROWLARR_TIMEOUT_SECONDS": bool(self.apis["PROWLARR_TIMEOUT_SECONDS"]),
|
||||
"DELUGE_URL": bool(self.apis["DELUGE_URL"]),
|
||||
"DELUGE_VERIFY_TLS": bool(self.apis["DELUGE_VERIFY_TLS"]),
|
||||
"DELUGE_TIMEOUT_SECONDS": bool(self.apis["DELUGE_TIMEOUT_SECONDS"]),
|
||||
"DELUGE_DEFAULT_HOST_ID": bool(self.apis["DELUGE_DEFAULT_HOST_ID"]),
|
||||
"DELUGE_WEB_PATH": bool(self.apis["DELUGE_WEB_PATH"]),
|
||||
"PLEX_URL": bool(self.apis["PLEX_URL"]),
|
||||
"PLEX_VERIFY_TLS": bool(self.apis["PLEX_VERIFY_TLS"]),
|
||||
"PLEX_TIMEOUT_SECONDS": bool(self.apis["PLEX_TIMEOUT_SECONDS"]),
|
||||
"PLEX_DEFAULT_LIBRARY_ID": bool(self.apis["PLEX_DEFAULT_LIBRARY_ID"]),
|
||||
"PLEX_MACHINE_IDENTIFIER": bool(self.apis["PLEX_MACHINE_IDENTIFIER"]),
|
||||
"TAUTULLI_URL": bool(self.apis["TAUTULLI_URL"]),
|
||||
"TAUTULLI_VERIFY_TLS": bool(self.apis["TAUTULLI_VERIFY_TLS"]),
|
||||
"TAUTULLI_TIMEOUT_SECONDS": bool(self.apis["TAUTULLI_TIMEOUT_SECONDS"]),
|
||||
"NPM_URL": bool(self.apis["NPM_URL"]),
|
||||
"NPM_VERIFY_TLS": bool(self.apis["NPM_VERIFY_TLS"]),
|
||||
"NPM_TIMEOUT_SECONDS": bool(self.apis["NPM_TIMEOUT_SECONDS"]),
|
||||
"NPM_LETSENCRYPT_EMAIL": bool(self.apis["NPM_LETSENCRYPT_EMAIL"]),
|
||||
"AUTHENTIK_URL": bool(self.apis["AUTHENTIK_URL"]),
|
||||
"AUTHENTIK_VERIFY_TLS": bool(self.apis["AUTHENTIK_VERIFY_TLS"]),
|
||||
"AUTHENTIK_TIMEOUT_SECONDS": bool(self.apis["AUTHENTIK_TIMEOUT_SECONDS"]),
|
||||
"AUTHENTIK_OUTPOST_URL": bool(self.apis["AUTHENTIK_OUTPOST_URL"]),
|
||||
"AUTHENTIK_EXPECT_EMBEDDED_OUTPOST": bool(self.apis["AUTHENTIK_EXPECT_EMBEDDED_OUTPOST"]),
|
||||
"AUTHENTIK_OIDC_AUTHORIZATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_AUTHORIZATION_FLOW"]),
|
||||
"AUTHENTIK_OIDC_INVALIDATION_FLOW": bool(self.apis["AUTHENTIK_OIDC_INVALIDATION_FLOW"]),
|
||||
"AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING": bool(self.apis["AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING"]),
|
||||
"AUTHENTIK_OIDC_ROLE_CLAIM": bool(self.apis["AUTHENTIK_OIDC_ROLE_CLAIM"]),
|
||||
"JELLYFIN_URL": bool(self.apis["JELLYFIN_URL"]),
|
||||
"WIDEFROG_URL": bool(self.apis["WIDEFROG_URL"]),
|
||||
"WIDEFROG_VERIFY_TLS": bool(self.apis["WIDEFROG_VERIFY_TLS"]),
|
||||
"WIDEFROG_TIMEOUT_SECONDS": bool(self.apis["WIDEFROG_TIMEOUT_SECONDS"]),
|
||||
"HOME_ASSISTANT_URL": bool(self.apis["HOME_ASSISTANT_URL"]),
|
||||
"HOME_ASSISTANT_VERIFY_TLS": bool(self.apis["HOME_ASSISTANT_VERIFY_TLS"]),
|
||||
"HOME_ASSISTANT_TIMEOUT_SECONDS": bool(self.apis["HOME_ASSISTANT_TIMEOUT_SECONDS"]),
|
||||
"HOME_ASSISTANT_NOTIFY_DOMAIN": bool(self.apis["HOME_ASSISTANT_NOTIFY_DOMAIN"]),
|
||||
"HOME_ASSISTANT_NOTIFY_SERVICE": bool(self.apis["HOME_ASSISTANT_NOTIFY_SERVICE"]),
|
||||
"HOME_ASSISTANT_NOTIFY_TARGET": bool(self.apis["HOME_ASSISTANT_NOTIFY_TARGET"]),
|
||||
"HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX": bool(self.apis["HOME_ASSISTANT_NOTIFICATION_TITLE_PREFIX"]),
|
||||
"HOME_ASSISTANT_DRY_RUN": bool(self.apis["HOME_ASSISTANT_DRY_RUN"]),
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
|
||||
class UnsafeConnectorUrl(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain", "metadata.google.internal"}
|
||||
_BLOCKED_IPS = {
|
||||
ipaddress.ip_address("169.254.169.254"),
|
||||
ipaddress.ip_address("100.100.100.200"),
|
||||
}
|
||||
|
||||
|
||||
def _address_is_forbidden(address: ipaddress.IPv4Address | ipaddress.IPv6Address, *, allow_loopback: bool) -> bool:
|
||||
return (
|
||||
address in _BLOCKED_IPS
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
or (address.is_loopback and not allow_loopback)
|
||||
)
|
||||
|
||||
|
||||
def validate_connector_url(value: str, *, allow_loopback: bool = False) -> str:
|
||||
"""Validate a configured LAN connector URL and return a normalized value."""
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urlsplit(raw)
|
||||
if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname:
|
||||
raise UnsafeConnectorUrl("connector_url_requires_http_or_https")
|
||||
if parsed.username or parsed.password or parsed.fragment:
|
||||
raise UnsafeConnectorUrl("connector_url_contains_forbidden_components")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise UnsafeConnectorUrl("connector_url_has_invalid_port") from exc
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise UnsafeConnectorUrl("connector_url_has_invalid_port")
|
||||
|
||||
hostname = parsed.hostname.rstrip(".").lower()
|
||||
if hostname in _BLOCKED_HOSTS and not allow_loopback:
|
||||
raise UnsafeConnectorUrl("connector_url_loopback_blocked")
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
address = None
|
||||
if address is not None:
|
||||
if _address_is_forbidden(address, allow_loopback=allow_loopback):
|
||||
raise UnsafeConnectorUrl("connector_url_reserved_address_blocked")
|
||||
else:
|
||||
# Inspect currently resolvable names as well as literal IPs. Unresolved LAN
|
||||
# names remain configurable, but DNS names resolving to local-only system
|
||||
# addresses cannot bypass the literal-address checks.
|
||||
try:
|
||||
resolved = {item[4][0] for item in socket.getaddrinfo(hostname, port or 0, type=socket.SOCK_STREAM)}
|
||||
except socket.gaierror:
|
||||
resolved = set()
|
||||
for resolved_value in resolved:
|
||||
resolved_address = ipaddress.ip_address(resolved_value)
|
||||
if _address_is_forbidden(resolved_address, allow_loopback=allow_loopback):
|
||||
raise UnsafeConnectorUrl("connector_url_resolves_to_reserved_address")
|
||||
|
||||
normalized_path = parsed.path.rstrip("/")
|
||||
return urlunsplit((parsed.scheme.lower(), parsed.netloc, normalized_path, parsed.query, ""))
|
||||
@@ -0,0 +1,34 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
REDACTION_PATTERNS = [
|
||||
re.compile(r"(Bearer\s+)([A-Za-z0-9._~+/=-]{10,})", re.IGNORECASE),
|
||||
re.compile(r"(api[-_]?(?:key|token)\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
re.compile(r"(apiKey\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
re.compile(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
re.compile(r"(token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
re.compile(r"(password\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
re.compile(r"(access[_-]?token\s*[:=]\s*)([^\s\"']+)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
_SENSITIVE_KEY_PARTS = ("api_key", "apikey", "token", "password", "secret", "authorization", "cookie")
|
||||
|
||||
|
||||
def redact_message(message: str) -> str:
|
||||
redacted = message
|
||||
for pattern in REDACTION_PATTERNS:
|
||||
redacted = pattern.sub(r"\1[REDACTED]", redacted)
|
||||
return redacted
|
||||
|
||||
|
||||
def redact_value(value: Any, key: str = "") -> Any:
|
||||
"""Recursively redact values crossing log, AI, MCP or automation boundaries."""
|
||||
if any(part in key.casefold() for part in _SENSITIVE_KEY_PARTS):
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, dict):
|
||||
return {str(nested_key): redact_value(nested, str(nested_key)) for nested_key, nested in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [redact_value(item) for item in value]
|
||||
if isinstance(value, str):
|
||||
return redact_message(value)
|
||||
return value
|
||||
@@ -0,0 +1,255 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
from ipaddress import ip_address, ip_network
|
||||
from time import monotonic
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
OPERATOR_SESSION_COOKIE = "appops_operator_session"
|
||||
OPERATOR_SESSION_CSRF_HEADER = "X-AppOps-Session-CSRF"
|
||||
_OPERATOR_SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
_operator_sessions: dict[str, tuple[float, str]] = {}
|
||||
_operator_sessions_lock = threading.Lock()
|
||||
|
||||
|
||||
def _operator_token() -> str | None:
|
||||
expected = settings.operator_token or os.getenv("APPOPS_OPERATOR_TOKEN")
|
||||
token_file = settings.operator_token_file or os.getenv("APPOPS_OPERATOR_TOKEN_FILE")
|
||||
if expected or not token_file:
|
||||
return expected
|
||||
try:
|
||||
with open(token_file, encoding="utf-8") as handle:
|
||||
return handle.read().strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def token_is_strong(value: str | None, *, minimum_length: int = 24) -> bool:
|
||||
if not value or len(value) < minimum_length:
|
||||
return False
|
||||
return value.strip().lower() not in {"change-me", "changeme", "dev", "test", "dev-voice-token"}
|
||||
|
||||
|
||||
def _extract_bearer_token(authorization: str | None) -> str:
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
prefix = "Bearer "
|
||||
if not authorization.startswith(prefix):
|
||||
raise HTTPException(status_code=401, detail="Authorization header missing")
|
||||
|
||||
return authorization[len(prefix):]
|
||||
|
||||
|
||||
def require_auth_token(provided_token: str | None, expected_token: str | None, scope: str):
|
||||
if expected_token:
|
||||
token = _extract_bearer_token(provided_token)
|
||||
if not hmac.compare_digest(token, expected_token):
|
||||
raise HTTPException(status_code=403, detail=f"Invalid {scope} token")
|
||||
|
||||
|
||||
def _operator_token_from_request(request: Request) -> str | None:
|
||||
explicit = request.headers.get("X-AppOps-Operator-Token")
|
||||
if explicit:
|
||||
return explicit.strip()
|
||||
authorization = request.headers.get("Authorization")
|
||||
if authorization and authorization.startswith("Bearer "):
|
||||
return authorization[len("Bearer "):].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _operator_token_fingerprint(value: str | None) -> str:
|
||||
return hashlib.sha256((value or "").encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _remove_expired_operator_sessions(now: float) -> None:
|
||||
expired = [session_id for session_id, (expires_at, _) in _operator_sessions.items() if expires_at <= now]
|
||||
for session_id in expired:
|
||||
_operator_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def issue_operator_session(request: Request) -> str:
|
||||
"""Exchange the explicit operator token for an opaque, process-local browser session."""
|
||||
expected = _operator_token()
|
||||
provided = _operator_token_from_request(request)
|
||||
auth_required = settings.operator_auth_required
|
||||
if not auth_required:
|
||||
raise HTTPException(status_code=409, detail="Operator sessions are disabled")
|
||||
if not provided:
|
||||
raise HTTPException(status_code=401, detail="Operator token missing")
|
||||
if not expected:
|
||||
if auth_required:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
elif settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
elif not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid operator token")
|
||||
|
||||
session_id = secrets.token_urlsafe(32)
|
||||
now = monotonic()
|
||||
with _operator_sessions_lock:
|
||||
_remove_expired_operator_sessions(now)
|
||||
_operator_sessions[session_id] = (
|
||||
now + _OPERATOR_SESSION_TTL_SECONDS,
|
||||
_operator_token_fingerprint(expected),
|
||||
)
|
||||
return session_id
|
||||
|
||||
|
||||
def revoke_operator_session(request: Request) -> None:
|
||||
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
|
||||
if not session_id:
|
||||
return
|
||||
with _operator_sessions_lock:
|
||||
_operator_sessions.pop(session_id, None)
|
||||
|
||||
|
||||
def _operator_session_identity(request: Request, expected: str | None) -> str | None:
|
||||
session_id = request.cookies.get(OPERATOR_SESSION_COOKIE, "")
|
||||
if not session_id:
|
||||
return None
|
||||
now = monotonic()
|
||||
with _operator_sessions_lock:
|
||||
_remove_expired_operator_sessions(now)
|
||||
record = _operator_sessions.get(session_id)
|
||||
if record and not hmac.compare_digest(record[1], _operator_token_fingerprint(expected)):
|
||||
_operator_sessions.pop(session_id, None)
|
||||
record = None
|
||||
if not record:
|
||||
return None
|
||||
if request.method.upper() not in {"GET", "HEAD", "OPTIONS"}:
|
||||
csrf = request.headers.get(OPERATOR_SESSION_CSRF_HEADER, "")
|
||||
if not hmac.compare_digest(csrf, "1"):
|
||||
raise HTTPException(status_code=403, detail="Operator session CSRF validation failed")
|
||||
return "session"
|
||||
|
||||
|
||||
def _normalized_set(values: list[str] | None) -> set[str]:
|
||||
return {value.strip().casefold() for value in values or [] if value.strip()}
|
||||
|
||||
|
||||
def _request_from_trusted_operator_proxy(request: Request) -> bool:
|
||||
if not settings.operator_proxy_auth_enabled or not settings.operator_proxy_trusted_networks:
|
||||
return False
|
||||
client_host = request.client.host if request.client else ""
|
||||
try:
|
||||
client_ip = ip_address(client_host)
|
||||
except ValueError:
|
||||
return False
|
||||
for configured_network in settings.operator_proxy_trusted_networks:
|
||||
try:
|
||||
if client_ip in ip_network(configured_network, strict=False):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _trusted_proxy_operator_identity(request: Request) -> str | None:
|
||||
"""Resolve an Authentik identity only across an explicitly trusted proxy boundary."""
|
||||
if not _request_from_trusted_operator_proxy(request):
|
||||
return None
|
||||
|
||||
allowed_users = _normalized_set(settings.operator_proxy_users)
|
||||
allowed_groups = _normalized_set(settings.operator_proxy_groups)
|
||||
if not allowed_users and not allowed_groups:
|
||||
return None
|
||||
|
||||
username = request.headers.get("X-authentik-username", "").strip()
|
||||
groups_header = request.headers.get("X-authentik-groups", "")
|
||||
groups = {
|
||||
item.strip().casefold()
|
||||
for item in re.split(r"[|,;]", groups_header)
|
||||
if item.strip()
|
||||
}
|
||||
if username and (username.casefold() in allowed_users or groups.intersection(allowed_groups)):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def require_operator_request(request: Request) -> str:
|
||||
"""Validate the local operator boundary without leaking token material."""
|
||||
expected = _operator_token()
|
||||
auth_required = settings.operator_auth_required
|
||||
if not auth_required:
|
||||
return "private_unlocked"
|
||||
proxy_identity = _trusted_proxy_operator_identity(request)
|
||||
if proxy_identity:
|
||||
return f"authentik:{proxy_identity}"
|
||||
|
||||
provided = _operator_token_from_request(request)
|
||||
if provided:
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
if settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid operator token")
|
||||
return "operator"
|
||||
|
||||
session_identity = _operator_session_identity(request, expected)
|
||||
if session_identity:
|
||||
return session_identity
|
||||
|
||||
if not expected:
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is not configured")
|
||||
if settings.environment == "production" and not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail="Operator authentication is configured with an unsafe token")
|
||||
raise HTTPException(status_code=401, detail="Operator authorization missing")
|
||||
|
||||
|
||||
def operator_session_status(request: Request) -> dict[str, str | bool | None]:
|
||||
"""Return the current request's operator state without exposing credentials."""
|
||||
try:
|
||||
identity = require_operator_request(request)
|
||||
except HTTPException:
|
||||
return {"authorized": False, "mode": "locked", "identity": None}
|
||||
if identity.startswith("authentik:"):
|
||||
return {"authorized": True, "mode": "account", "identity": identity.split(":", 1)[1]}
|
||||
if identity == "session":
|
||||
return {"authorized": True, "mode": "session", "identity": None}
|
||||
return {"authorized": True, "mode": "token" if identity == "operator" else "private", "identity": None}
|
||||
|
||||
|
||||
def require_service_request(request: Request, service: str) -> str:
|
||||
"""Authenticate a narrowly scoped machine integration without reusing operator credentials."""
|
||||
if service == "mcp":
|
||||
enabled, expected = settings.mcp_enabled, settings.mcp_token
|
||||
elif service == "n8n":
|
||||
enabled, expected = settings.n8n_enabled, settings.n8n_token
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Unknown service identity")
|
||||
if not enabled:
|
||||
raise HTTPException(status_code=404, detail=f"{service} integration is disabled")
|
||||
if not token_is_strong(expected):
|
||||
raise HTTPException(status_code=503, detail=f"{service} authentication is not safely configured")
|
||||
provided = _operator_token_from_request(request)
|
||||
if not provided:
|
||||
raise HTTPException(status_code=401, detail=f"{service} authorization missing")
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail=f"Invalid {service} token")
|
||||
return service
|
||||
|
||||
|
||||
def n8n_auth(request: Request) -> str:
|
||||
return require_service_request(request, "n8n")
|
||||
|
||||
|
||||
def operator_auth(request: Request) -> str:
|
||||
return require_operator_request(request)
|
||||
|
||||
|
||||
def voice_auth(authorization: str | None = Header(default=None, alias='Authorization')):
|
||||
expected = settings.voice_token or os.getenv("APP_OPS_VOICE_TOKEN")
|
||||
if not expected:
|
||||
return True
|
||||
|
||||
require_auth_token(authorization, expected, "voice")
|
||||
return True
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC as a naive datetime for existing SQLite columns."""
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -0,0 +1 @@
|
||||
APP_VERSION = "1.8.0"
|
||||
Reference in new issue
Block a user