Use LoadCredential secrets and DynamicUser for tailscalesd.

This commit is contained in:
Abel Luck 2026-03-05 15:56:06 +01:00
parent d5201b471b
commit 6870186009
5 changed files with 134 additions and 44 deletions

View file

@ -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

View file

@ -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";
}
];
};

View file

@ -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";
};
};
};

View file

@ -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,

View file

@ -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"