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).
|
- **tailnet** (`TAILSCALESD_TAILNET`): The Tailscale tailnet identifier (required).
|
||||||
- **client_id** (`TAILSCALESD_CLIENT_ID`): The Tailscale oauth client id (required).
|
- **client_id** (`TAILSCALESD_CLIENT_ID`): The Tailscale oauth client id (required).
|
||||||
- **client_secret** (`TAILSCALESD_CLIENT_SECRET`): The Tailscale oauth client secret (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
|
#### Environment File
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,9 @@
|
||||||
{
|
{
|
||||||
system.stateVersion = "24.11";
|
system.stateVersion = "24.11";
|
||||||
services.tailscalesd.enable = true;
|
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.";
|
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 {
|
environment = mkOption {
|
||||||
type = types.attrsOf types.str;
|
type = types.attrsOf types.str;
|
||||||
default = { };
|
default = { };
|
||||||
description = "Extra environment variables for tailscalesd.";
|
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 {
|
host = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
default = "127.0.0.1";
|
default = "127.0.0.1";
|
||||||
|
|
@ -90,19 +94,20 @@ in
|
||||||
assertion = cfg.package != null;
|
assertion = cfg.package != null;
|
||||||
message = "services.tailscalesd.package must be set when services.tailscalesd.enable = true.";
|
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 ];
|
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall [ cfg.port ];
|
||||||
|
|
||||||
systemd.services.tailscalesd = {
|
systemd.services.tailscalesd = {
|
||||||
|
|
@ -112,8 +117,7 @@ in
|
||||||
after = [ "network-online.target" ];
|
after = [ "network-online.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "simple";
|
Type = "simple";
|
||||||
User = cfg.user;
|
DynamicUser = true;
|
||||||
Group = cfg.group;
|
|
||||||
ExecStart = execStart;
|
ExecStart = execStart;
|
||||||
Restart = "on-failure";
|
Restart = "on-failure";
|
||||||
RestartSec = "5s";
|
RestartSec = "5s";
|
||||||
|
|
@ -123,15 +127,19 @@ in
|
||||||
ProtectSystem = "strict";
|
ProtectSystem = "strict";
|
||||||
WorkingDirectory = "/var/lib/tailscalesd";
|
WorkingDirectory = "/var/lib/tailscalesd";
|
||||||
StateDirectory = "tailscalesd";
|
StateDirectory = "tailscalesd";
|
||||||
}
|
LoadCredential = [
|
||||||
// lib.optionalAttrs (cfg.environmentFile != null) {
|
"bearer_token:${cfg.credentials.bearerTokenFile}"
|
||||||
EnvironmentFile = cfg.environmentFile;
|
"client_id:${cfg.credentials.clientIdFile}"
|
||||||
|
"client_secret:${cfg.credentials.clientSecretFile}"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
environment = cfg.environment // {
|
environment = cfg.environment // {
|
||||||
TAILSCALESD_HOST = cfg.host;
|
TAILSCALESD_HOST = cfg.host;
|
||||||
TAILSCALESD_PORT = toString cfg.port;
|
TAILSCALESD_PORT = toString cfg.port;
|
||||||
TAILSCALESD_INTERVAL = toString cfg.interval;
|
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 datetime import datetime, timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from ipaddress import ip_address
|
from ipaddress import ip_address
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Dict, List, Optional
|
from typing import Annotated, Dict, List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -15,7 +16,7 @@ from fastapi import Depends, FastAPI, HTTPException, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from prometheus_client import Counter, Gauge
|
from prometheus_client import Counter, Gauge
|
||||||
from prometheus_fastapi_instrumentator import Instrumentator
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
from pydantic import Field, SecretStr
|
from pydantic import Field, SecretStr, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
MATRIX_TAG = "tag:matrix"
|
MATRIX_TAG = "tag:matrix"
|
||||||
|
|
@ -60,10 +61,47 @@ class Settings(BaseSettings):
|
||||||
port: int = 9242
|
port: int = 9242
|
||||||
interval: int = 60
|
interval: int = 60
|
||||||
tailnet: str = Field()
|
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
|
test_mode: bool = False
|
||||||
client_id: SecretStr = Field()
|
client_id: Optional[SecretStr] = Field(default=None)
|
||||||
client_secret: SecretStr = Field()
|
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 = []
|
CACHE_SD = []
|
||||||
|
|
@ -275,9 +313,15 @@ async def poll_sd(settings: Settings):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
if not access_token or access_token.is_expiring_soon():
|
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(
|
access_token = await get_access_token(
|
||||||
settings.client_id.get_secret_value(),
|
client_id.get_secret_value(),
|
||||||
settings.client_secret.get_secret_value(),
|
client_secret.get_secret_value(),
|
||||||
)
|
)
|
||||||
|
|
||||||
devices = await tailscale_devices(settings, access_token)
|
devices = await tailscale_devices(settings, access_token)
|
||||||
|
|
@ -322,9 +366,15 @@ async def is_authorized(
|
||||||
settings: Annotated[Settings, Depends(get_settings)],
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
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 (
|
if (
|
||||||
credentials.scheme != "Bearer"
|
credentials.scheme != "Bearer"
|
||||||
or credentials.credentials != settings.bearer_token
|
or credentials.credentials != bearer_token.get_secret_value()
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,27 @@ def test_unauthorized_no_token():
|
||||||
response = client.get("/")
|
response = client.get("/")
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.json() == {"detail": "Not authenticated"}
|
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