diff --git a/README.md b/README.md index 027ff11..b5aa9de 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ Configuration values can be set using environment variables, or optionally loade - **tailnet** (`TAILSCALESD_TAILNET`): The Tailscale tailnet identifier (required). - **client_id** (`TAILSCALESD_CLIENT_ID`): The Tailscale oauth client id (required). - **client_secret** (`TAILSCALESD_CLIENT_SECRET`): The Tailscale oauth client secret (required). +- File-based secret alternatives: + - `TAILSCALESD_BEARER_TOKEN_FILE` + - `TAILSCALESD_CLIENT_ID_FILE` + - `TAILSCALESD_CLIENT_SECRET_FILE` + +For systemd services, prefer `LoadCredential=` plus `%d` (runtime `CREDENTIALS_DIRECTORY`) and set `TAILSCALESD_*_FILE` values to files under `%d`. #### Environment File diff --git a/flake.nix b/flake.nix index 3131f03..058f500 100644 --- a/flake.nix +++ b/flake.nix @@ -117,7 +117,9 @@ { system.stateVersion = "24.11"; services.tailscalesd.enable = true; - services.tailscalesd.environmentFile = "/dev/null"; + services.tailscalesd.credentials.bearerTokenFile = "/dev/null"; + services.tailscalesd.credentials.clientIdFile = "/dev/null"; + services.tailscalesd.credentials.clientSecretFile = "/dev/null"; } ]; }; diff --git a/nix/modules/nixos/services/tailscalesd.nix b/nix/modules/nixos/services/tailscalesd.nix index 04655e3..4cde41d 100644 --- a/nix/modules/nixos/services/tailscalesd.nix +++ b/nix/modules/nixos/services/tailscalesd.nix @@ -28,31 +28,35 @@ in description = "Package that provides the tailscalesd executable."; }; - user = mkOption { - type = types.str; - default = "tailscalesd"; - description = "System user for the tailscalesd process."; - }; - - group = mkOption { - type = types.str; - default = "tailscalesd"; - description = "System group for the tailscalesd process."; - }; - - environmentFile = mkOption { - type = types.nullOr types.str; - default = null; - example = "/run/secrets/tailscalesd.env"; - description = "Optional EnvironmentFile with TAILSCALESD_* values, including secrets."; - }; - environment = mkOption { type = types.attrsOf types.str; default = { }; description = "Extra environment variables for tailscalesd."; }; + credentials = { + bearerTokenFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/tailscalesd/bearer-token"; + description = "Path to bearer token secret loaded with systemd LoadCredential."; + }; + + clientIdFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/tailscalesd/client-id"; + description = "Path to Tailscale OAuth client id secret loaded with systemd LoadCredential."; + }; + + clientSecretFile = mkOption { + type = types.nullOr types.str; + default = null; + example = "/run/secrets/tailscalesd/client-secret"; + description = "Path to Tailscale OAuth client secret loaded with systemd LoadCredential."; + }; + }; + host = mkOption { type = types.str; default = "127.0.0.1"; @@ -90,19 +94,20 @@ in assertion = cfg.package != null; message = "services.tailscalesd.package must be set when services.tailscalesd.enable = true."; } + { + assertion = cfg.credentials.bearerTokenFile != null; + message = "services.tailscalesd.credentials.bearerTokenFile must be set when services.tailscalesd.enable = true."; + } + { + assertion = cfg.credentials.clientIdFile != null; + message = "services.tailscalesd.credentials.clientIdFile must be set when services.tailscalesd.enable = true."; + } + { + assertion = cfg.credentials.clientSecretFile != null; + message = "services.tailscalesd.credentials.clientSecretFile must be set when services.tailscalesd.enable = true."; + } ]; - users.groups = mkIf (cfg.group == "tailscalesd") { - tailscalesd = { }; - }; - - users.users = mkIf (cfg.user == "tailscalesd") { - tailscalesd = { - isSystemUser = true; - group = cfg.group; - }; - }; - networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ cfg.port ]; systemd.services.tailscalesd = { @@ -112,8 +117,7 @@ in after = [ "network-online.target" ]; serviceConfig = { Type = "simple"; - User = cfg.user; - Group = cfg.group; + DynamicUser = true; ExecStart = execStart; Restart = "on-failure"; RestartSec = "5s"; @@ -123,15 +127,19 @@ in ProtectSystem = "strict"; WorkingDirectory = "/var/lib/tailscalesd"; StateDirectory = "tailscalesd"; - } - // lib.optionalAttrs (cfg.environmentFile != null) { - EnvironmentFile = cfg.environmentFile; + LoadCredential = [ + "bearer_token:${cfg.credentials.bearerTokenFile}" + "client_id:${cfg.credentials.clientIdFile}" + "client_secret:${cfg.credentials.clientSecretFile}" + ]; }; - environment = cfg.environment // { TAILSCALESD_HOST = cfg.host; TAILSCALESD_PORT = toString cfg.port; TAILSCALESD_INTERVAL = toString cfg.interval; + TAILSCALESD_BEARER_TOKEN_FILE = "%d/bearer_token"; + TAILSCALESD_CLIENT_ID_FILE = "%d/client_id"; + TAILSCALESD_CLIENT_SECRET_FILE = "%d/client_secret"; }; }; }; diff --git a/tailscalesd/main.py b/tailscalesd/main.py index ff2b795..aa650b0 100644 --- a/tailscalesd/main.py +++ b/tailscalesd/main.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager from datetime import datetime, timedelta from functools import lru_cache from ipaddress import ip_address +from pathlib import Path from typing import Annotated, Dict, List, Optional import httpx @@ -15,7 +16,7 @@ from fastapi import Depends, FastAPI, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from prometheus_client import Counter, Gauge from prometheus_fastapi_instrumentator import Instrumentator -from pydantic import Field, SecretStr +from pydantic import Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict MATRIX_TAG = "tag:matrix" @@ -60,10 +61,47 @@ class Settings(BaseSettings): port: int = 9242 interval: int = 60 tailnet: str = Field() - bearer_token: str = Field() + bearer_token: Optional[SecretStr] = Field(default=None) + bearer_token_file: Optional[str] = Field(default=None) test_mode: bool = False - client_id: SecretStr = Field() - client_secret: SecretStr = Field() + client_id: Optional[SecretStr] = Field(default=None) + client_id_file: Optional[str] = Field(default=None) + client_secret: Optional[SecretStr] = Field(default=None) + client_secret_file: Optional[str] = Field(default=None) + + @staticmethod + def _load_secret( + value: Optional[SecretStr], file_path: Optional[str] + ) -> Optional[SecretStr]: + if value is not None: + return value + if file_path is None: + return None + secret = Path(file_path).read_text(encoding="utf-8").strip() + return SecretStr(secret) + + @model_validator(mode="after") + def resolve_secret_sources(self): + self.bearer_token = self._load_secret(self.bearer_token, self.bearer_token_file) + self.client_id = self._load_secret(self.client_id, self.client_id_file) + self.client_secret = self._load_secret( + self.client_secret, self.client_secret_file + ) + + missing = [] + if self.bearer_token is None: + missing.append("TAILSCALESD_BEARER_TOKEN or TAILSCALESD_BEARER_TOKEN_FILE") + if self.client_id is None: + missing.append("TAILSCALESD_CLIENT_ID or TAILSCALESD_CLIENT_ID_FILE") + if self.client_secret is None: + missing.append( + "TAILSCALESD_CLIENT_SECRET or TAILSCALESD_CLIENT_SECRET_FILE" + ) + + if missing: + raise ValueError(f"Missing required settings: {', '.join(missing)}") + + return self CACHE_SD = [] @@ -275,9 +313,15 @@ async def poll_sd(settings: Settings): while True: try: if not access_token or access_token.is_expiring_soon(): + client_id = settings.client_id + client_secret = settings.client_secret + if client_id is None or client_secret is None: + raise RuntimeError( + "settings validation failed for oauth credentials" + ) access_token = await get_access_token( - settings.client_id.get_secret_value(), - settings.client_secret.get_secret_value(), + client_id.get_secret_value(), + client_secret.get_secret_value(), ) devices = await tailscale_devices(settings, access_token) @@ -322,9 +366,15 @@ async def is_authorized( settings: Annotated[Settings, Depends(get_settings)], credentials: HTTPAuthorizationCredentials = Depends(security), ): + bearer_token = settings.bearer_token + if bearer_token is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Service is missing bearer token configuration", + ) if ( credentials.scheme != "Bearer" - or credentials.credentials != settings.bearer_token + or credentials.credentials != bearer_token.get_secret_value() ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, diff --git a/tests/test_auth.py b/tests/test_auth.py index cc13d82..49eeed4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -33,3 +33,27 @@ def test_unauthorized_no_token(): response = client.get("/") assert response.status_code == 403 assert response.json() == {"detail": "Not authenticated"} + + +def test_settings_support_secret_files(tmp_path): + bearer_token_file = tmp_path / "bearer_token" + client_id_file = tmp_path / "client_id" + client_secret_file = tmp_path / "client_secret" + bearer_token_file.write_text("from-file-token\n", encoding="utf-8") + client_id_file.write_text("from-file-client-id\n", encoding="utf-8") + client_secret_file.write_text("from-file-client-secret\n", encoding="utf-8") + + settings = Settings( + test_mode=True, + tailnet="test", + bearer_token_file=str(bearer_token_file), + client_id_file=str(client_id_file), + client_secret_file=str(client_secret_file), + ) + + assert settings.bearer_token is not None + assert settings.client_id is not None + assert settings.client_secret is not None + assert settings.bearer_token.get_secret_value() == "from-file-token" + assert settings.client_id.get_secret_value() == "from-file-client-id" + assert settings.client_secret.get_secret_value() == "from-file-client-secret"