Use LoadCredential secrets and DynamicUser for tailscalesd.
This commit is contained in:
parent
d5201b471b
commit
6870186009
5 changed files with 134 additions and 44 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue