tailscale-manager 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tailscale_manager/__init__.py +3 -0
- tailscale_manager/cli.py +165 -0
- tailscale_manager/core/__init__.py +0 -0
- tailscale_manager/core/config.py +69 -0
- tailscale_manager/core/constants.py +11 -0
- tailscale_manager/core/exceptions.py +21 -0
- tailscale_manager/models/__init__.py +0 -0
- tailscale_manager/models/auth_key.py +18 -0
- tailscale_manager/py.typed +0 -0
- tailscale_manager/repositories/__init__.py +0 -0
- tailscale_manager/repositories/state_repository.py +65 -0
- tailscale_manager/services/__init__.py +0 -0
- tailscale_manager/services/terraform_service.py +161 -0
- tailscale_manager/utils/__init__.py +0 -0
- tailscale_manager/utils/subprocess_helpers.py +46 -0
- tailscale_manager-0.1.0.dist-info/METADATA +550 -0
- tailscale_manager-0.1.0.dist-info/RECORD +23 -0
- tailscale_manager-0.1.0.dist-info/WHEEL +5 -0
- tailscale_manager-0.1.0.dist-info/entry_points.txt +2 -0
- tailscale_manager-0.1.0.dist-info/top_level.txt +2 -0
- textual_ui/__init__.py +3 -0
- textual_ui/app.py +189 -0
- textual_ui/py.typed +0 -0
tailscale_manager/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from tailscale_manager.core.config import AppConfig
|
|
10
|
+
from tailscale_manager.repositories.state_repository import StateRepository
|
|
11
|
+
from tailscale_manager.services.terraform_service import TerraformService
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
name="tailscale-manager",
|
|
15
|
+
help="Manage Tailscale auth keys via Terraform",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_config() -> AppConfig:
|
|
20
|
+
config = AppConfig.from_env()
|
|
21
|
+
config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def _main() -> None:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def init() -> None:
|
|
32
|
+
config = _load_config()
|
|
33
|
+
svc = TerraformService(config)
|
|
34
|
+
svc.generate_config()
|
|
35
|
+
output = svc.init()
|
|
36
|
+
print(output)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def plan() -> None:
|
|
41
|
+
config = _load_config()
|
|
42
|
+
svc = TerraformService(config)
|
|
43
|
+
svc.generate_config()
|
|
44
|
+
svc.init()
|
|
45
|
+
output = svc.plan()
|
|
46
|
+
print(output)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command()
|
|
50
|
+
def apply() -> None:
|
|
51
|
+
config = _load_config()
|
|
52
|
+
svc = TerraformService(config)
|
|
53
|
+
result = svc.apply()
|
|
54
|
+
print(json.dumps(result, indent=2))
|
|
55
|
+
if result["result"] == "ok":
|
|
56
|
+
print("Apply succeeded")
|
|
57
|
+
else:
|
|
58
|
+
print(f"Apply failed: {result.get('error_message', '')}", file=sys.stderr)
|
|
59
|
+
raise typer.Exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def destroy() -> None:
|
|
64
|
+
config = _load_config()
|
|
65
|
+
svc = TerraformService(config)
|
|
66
|
+
result = svc.destroy()
|
|
67
|
+
print(json.dumps(result, indent=2))
|
|
68
|
+
if result["result"] == "ok":
|
|
69
|
+
print("Destroy succeeded")
|
|
70
|
+
else:
|
|
71
|
+
print(f"Destroy failed: {result.get('error_message', '')}", file=sys.stderr)
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.command()
|
|
76
|
+
def status(
|
|
77
|
+
json_output: bool = typer.Option(
|
|
78
|
+
False,
|
|
79
|
+
"--json",
|
|
80
|
+
help="Print last-apply.json to stdout instead of launching TUI",
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
config = _load_config()
|
|
84
|
+
repo = StateRepository(config.state_dir)
|
|
85
|
+
keys = repo.get_managed_keys()
|
|
86
|
+
last = repo.read_last_apply()
|
|
87
|
+
|
|
88
|
+
if json_output:
|
|
89
|
+
output: dict = {
|
|
90
|
+
"last_apply": last or {},
|
|
91
|
+
"managed_keys": [
|
|
92
|
+
{
|
|
93
|
+
"id": k.id,
|
|
94
|
+
"description": k.description,
|
|
95
|
+
"tags": k.tags,
|
|
96
|
+
"revoked": k.revoked,
|
|
97
|
+
}
|
|
98
|
+
for k in keys
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
print(json.dumps(output, indent=2, default=str))
|
|
102
|
+
if last and last.get("result") == "error":
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
print(f"Tailscale Manager — {config.tailnet}")
|
|
107
|
+
print(f"State dir: {config.state_dir}")
|
|
108
|
+
print()
|
|
109
|
+
|
|
110
|
+
if last:
|
|
111
|
+
print(f"Last apply: {last.get('timestamp', 'unknown')}")
|
|
112
|
+
print(f" Result: {last.get('result', 'unknown')}")
|
|
113
|
+
err = last.get("error_message")
|
|
114
|
+
if err:
|
|
115
|
+
print(f" Error: {err}")
|
|
116
|
+
else:
|
|
117
|
+
print("No apply has been run yet.")
|
|
118
|
+
|
|
119
|
+
print()
|
|
120
|
+
keys_file = config.state_dir / "terraform.tfstate"
|
|
121
|
+
print(f"Terraform state: {'found' if keys_file.exists() else 'not found'}")
|
|
122
|
+
|
|
123
|
+
print(f"Managed keys: {len(keys)}")
|
|
124
|
+
for k in keys:
|
|
125
|
+
status_icon = "✗" if k.revoked else "✓"
|
|
126
|
+
print(f" {status_icon} {k.id[:16] if k.id else '<no id>'} — {k.description or '<no desc>'}")
|
|
127
|
+
if k.tags:
|
|
128
|
+
print(f" tags: {', '.join(k.tags)}")
|
|
129
|
+
|
|
130
|
+
# Try launching TUI if available
|
|
131
|
+
try:
|
|
132
|
+
from textual_ui import run_status_app # type: ignore[import-untyped]
|
|
133
|
+
|
|
134
|
+
run_status_app(config, keys, last)
|
|
135
|
+
except ImportError:
|
|
136
|
+
pass
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
print(f"TUI unavailable: {exc}", file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.command()
|
|
142
|
+
def backup_state() -> None:
|
|
143
|
+
config = _load_config()
|
|
144
|
+
svc = TerraformService(config)
|
|
145
|
+
svc._backup_state()
|
|
146
|
+
print(f"Backed up state in {config.state_dir / 'backups'}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@app.command()
|
|
150
|
+
def restore_state() -> None:
|
|
151
|
+
config = _load_config()
|
|
152
|
+
svc = TerraformService(config)
|
|
153
|
+
svc._restore_state()
|
|
154
|
+
print("Restored most recent state backup")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@app.command()
|
|
158
|
+
def version() -> None:
|
|
159
|
+
"""Show tailscale-manager version."""
|
|
160
|
+
v = importlib.metadata.version("tailscale-manager")
|
|
161
|
+
print(f"tailscale-manager {v}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main() -> None:
|
|
165
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AppConfig(BaseModel):
|
|
10
|
+
tailnet: str = Field(
|
|
11
|
+
description="Tailscale tailnet name (e.g. example.com)"
|
|
12
|
+
)
|
|
13
|
+
state_dir: Path = Field(
|
|
14
|
+
default=Path("/var/lib/tailscale-manager"),
|
|
15
|
+
description="Directory for Terraform state and backups",
|
|
16
|
+
)
|
|
17
|
+
terraform_bin: Path = Field(
|
|
18
|
+
default=Path("terraform"),
|
|
19
|
+
description="Path to the terraform binary",
|
|
20
|
+
)
|
|
21
|
+
backup_count: int = Field(
|
|
22
|
+
default=5,
|
|
23
|
+
ge=1,
|
|
24
|
+
description="Number of tfstate backups to retain",
|
|
25
|
+
)
|
|
26
|
+
tags: list[str] = Field(
|
|
27
|
+
default_factory=list,
|
|
28
|
+
description="Tags to apply to the managed auth key",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_env(cls) -> AppConfig:
|
|
33
|
+
tailnet = os.environ.get("TAILSCALE_TAILNET")
|
|
34
|
+
if not tailnet:
|
|
35
|
+
# Fallback: the Tailscale Terraform provider's canonical env var
|
|
36
|
+
tailnet = os.environ.get("TAILSCALE_OAUTH_TAILNET", "")
|
|
37
|
+
if not tailnet:
|
|
38
|
+
raise ConfigurationError(
|
|
39
|
+
"TAILSCALE_TAILNET environment variable is required"
|
|
40
|
+
)
|
|
41
|
+
state_dir = Path(
|
|
42
|
+
os.environ.get(
|
|
43
|
+
"TAILSCALE_MANAGER_STATE_DIR",
|
|
44
|
+
"/var/lib/tailscale-manager",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
terraform_bin = Path(
|
|
48
|
+
os.environ.get(
|
|
49
|
+
"TAILSCALE_MANAGER_TERRAFORM_BIN",
|
|
50
|
+
"terraform",
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
backup_count = int(
|
|
54
|
+
os.environ.get("TAILSCALE_MANAGER_BACKUP_COUNT", "5")
|
|
55
|
+
)
|
|
56
|
+
tags_raw = os.environ.get("TAILSCALE_MANAGER_TAGS", "")
|
|
57
|
+
tags = [t.strip() for t in tags_raw.split(",") if t.strip()]
|
|
58
|
+
|
|
59
|
+
return cls(
|
|
60
|
+
tailnet=tailnet,
|
|
61
|
+
state_dir=state_dir,
|
|
62
|
+
terraform_bin=terraform_bin,
|
|
63
|
+
backup_count=backup_count,
|
|
64
|
+
tags=tags,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ConfigurationError(Exception):
|
|
69
|
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
APP_NAME = "tailscale-manager"
|
|
4
|
+
VERSION = "0.1.0"
|
|
5
|
+
DEFAULT_STATE_DIR = "/var/lib/tailscale-manager"
|
|
6
|
+
PROVIDER_VERSION = "~> 0.29"
|
|
7
|
+
LAST_APPLY_FILE = "last-apply.json"
|
|
8
|
+
BACKUP_DIR = "backups"
|
|
9
|
+
TERRAFORM_DIR = ".terraform"
|
|
10
|
+
MAIN_TF_FILE = "main.tf.json"
|
|
11
|
+
STATE_FILE = "terraform.tfstate"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TemplateError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(TemplateError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceError(TemplateError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotFoundError(TemplateError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TerraformError(TemplateError):
|
|
21
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class TailscaleAuthKey:
|
|
9
|
+
id: str
|
|
10
|
+
description: str
|
|
11
|
+
tags: list[str] = field(default_factory=list)
|
|
12
|
+
expiry: datetime | None = None
|
|
13
|
+
revoked: bool = False
|
|
14
|
+
reusable: bool = True
|
|
15
|
+
ephemeral: bool = False
|
|
16
|
+
preauthorized: bool = True
|
|
17
|
+
created_at: datetime | None = None
|
|
18
|
+
key: str | None = None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tailscale_manager.models.auth_key import TailscaleAuthKey
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StateRepository:
|
|
11
|
+
def __init__(self, state_dir: Path) -> None:
|
|
12
|
+
self.state_file = state_dir / "terraform.tfstate"
|
|
13
|
+
self.last_apply_file = state_dir / "last-apply.json"
|
|
14
|
+
|
|
15
|
+
def read_state(self) -> dict | None:
|
|
16
|
+
if not self.state_file.exists():
|
|
17
|
+
return None
|
|
18
|
+
raw = self.state_file.read_text()
|
|
19
|
+
return json.loads(raw)
|
|
20
|
+
|
|
21
|
+
def get_managed_keys(self) -> list[TailscaleAuthKey]:
|
|
22
|
+
state = self.read_state()
|
|
23
|
+
if state is None:
|
|
24
|
+
return []
|
|
25
|
+
|
|
26
|
+
keys: list[TailscaleAuthKey] = []
|
|
27
|
+
resources = state.get("resources", [])
|
|
28
|
+
for res in resources:
|
|
29
|
+
if res.get("type") != "tailscale_tailnet_key":
|
|
30
|
+
continue
|
|
31
|
+
for instance in res.get("instances", []):
|
|
32
|
+
attrs = instance.get("attributes", {})
|
|
33
|
+
key = TailscaleAuthKey(
|
|
34
|
+
id=attrs.get("id", ""),
|
|
35
|
+
description=attrs.get("description", ""),
|
|
36
|
+
tags=attrs.get("tags", []),
|
|
37
|
+
expiry=self._parse_ts(attrs.get("expires")),
|
|
38
|
+
revoked=attrs.get("revoked", False),
|
|
39
|
+
reusable=attrs.get("reusable", True),
|
|
40
|
+
ephemeral=attrs.get("ephemeral", False),
|
|
41
|
+
preauthorized=attrs.get("preauthorized", True),
|
|
42
|
+
created_at=self._parse_ts(attrs.get("created_at")),
|
|
43
|
+
key=attrs.get("key"),
|
|
44
|
+
)
|
|
45
|
+
keys.append(key)
|
|
46
|
+
return keys
|
|
47
|
+
|
|
48
|
+
def read_last_apply(self) -> dict | None:
|
|
49
|
+
if not self.last_apply_file.exists():
|
|
50
|
+
return None
|
|
51
|
+
raw = self.last_apply_file.read_text()
|
|
52
|
+
return json.loads(raw)
|
|
53
|
+
|
|
54
|
+
def write_last_apply(self, data: dict) -> None:
|
|
55
|
+
self.last_apply_file.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
self.last_apply_file.write_text(json.dumps(data, indent=2) + "\n")
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _parse_ts(value: str | None) -> datetime | None:
|
|
60
|
+
if value is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tailscale_manager.core.config import AppConfig
|
|
9
|
+
from tailscale_manager.core.constants import (
|
|
10
|
+
BACKUP_DIR,
|
|
11
|
+
MAIN_TF_FILE,
|
|
12
|
+
PROVIDER_VERSION,
|
|
13
|
+
STATE_FILE,
|
|
14
|
+
)
|
|
15
|
+
from tailscale_manager.core.exceptions import TerraformError
|
|
16
|
+
from tailscale_manager.repositories.state_repository import StateRepository
|
|
17
|
+
from tailscale_manager.utils.subprocess_helpers import run_terraform
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TerraformService:
|
|
21
|
+
def __init__(self, config: AppConfig) -> None:
|
|
22
|
+
self.config = config
|
|
23
|
+
self.state_repo = StateRepository(config.state_dir)
|
|
24
|
+
|
|
25
|
+
def generate_config(self) -> Path:
|
|
26
|
+
self.config.state_dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
tf_path = self.config.state_dir / MAIN_TF_FILE
|
|
28
|
+
|
|
29
|
+
tags = self.config.tags
|
|
30
|
+
|
|
31
|
+
cfg = {
|
|
32
|
+
"terraform": {
|
|
33
|
+
"required_providers": {
|
|
34
|
+
"tailscale": {
|
|
35
|
+
"source": "tailscale/tailscale",
|
|
36
|
+
"version": PROVIDER_VERSION,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"provider": {
|
|
41
|
+
"tailscale": {}
|
|
42
|
+
},
|
|
43
|
+
"resource": {
|
|
44
|
+
"tailscale_tailnet_key": {
|
|
45
|
+
"managed_key": {
|
|
46
|
+
"reusable": True,
|
|
47
|
+
"ephemeral": False,
|
|
48
|
+
"preauthorized": True,
|
|
49
|
+
"tags": tags,
|
|
50
|
+
"recreate_if_invalid": "always",
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
tf_path.write_text(json.dumps(cfg, indent=2) + "\n")
|
|
57
|
+
return tf_path
|
|
58
|
+
|
|
59
|
+
def init(self) -> str:
|
|
60
|
+
return run_terraform(
|
|
61
|
+
self.config.terraform_bin,
|
|
62
|
+
["init", "-input=false"],
|
|
63
|
+
cwd=self.config.state_dir,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def plan(self) -> str:
|
|
67
|
+
return run_terraform(
|
|
68
|
+
self.config.terraform_bin,
|
|
69
|
+
["plan", "-input=false", "-detailed-exitcode"],
|
|
70
|
+
cwd=self.config.state_dir,
|
|
71
|
+
timeout=60,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def apply(self) -> dict:
|
|
75
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
76
|
+
try:
|
|
77
|
+
self._backup_state()
|
|
78
|
+
self.generate_config()
|
|
79
|
+
self.init()
|
|
80
|
+
run_terraform(
|
|
81
|
+
self.config.terraform_bin,
|
|
82
|
+
[
|
|
83
|
+
"apply",
|
|
84
|
+
"-input=false",
|
|
85
|
+
"-auto-approve",
|
|
86
|
+
],
|
|
87
|
+
cwd=self.config.state_dir,
|
|
88
|
+
timeout=180,
|
|
89
|
+
)
|
|
90
|
+
result = {
|
|
91
|
+
"timestamp": timestamp,
|
|
92
|
+
"result": "ok",
|
|
93
|
+
}
|
|
94
|
+
except TerraformError as exc:
|
|
95
|
+
self._restore_state()
|
|
96
|
+
result = {
|
|
97
|
+
"timestamp": timestamp,
|
|
98
|
+
"result": "error",
|
|
99
|
+
"error_message": str(exc),
|
|
100
|
+
}
|
|
101
|
+
self.state_repo.write_last_apply(result)
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
def destroy(self) -> dict:
|
|
105
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
106
|
+
try:
|
|
107
|
+
self._backup_state()
|
|
108
|
+
run_terraform(
|
|
109
|
+
self.config.terraform_bin,
|
|
110
|
+
[
|
|
111
|
+
"destroy",
|
|
112
|
+
"-input=false",
|
|
113
|
+
"-auto-approve",
|
|
114
|
+
],
|
|
115
|
+
cwd=self.config.state_dir,
|
|
116
|
+
timeout=180,
|
|
117
|
+
)
|
|
118
|
+
result = {
|
|
119
|
+
"timestamp": timestamp,
|
|
120
|
+
"result": "ok",
|
|
121
|
+
"action": "destroy",
|
|
122
|
+
}
|
|
123
|
+
except TerraformError as exc:
|
|
124
|
+
self._restore_state()
|
|
125
|
+
result = {
|
|
126
|
+
"timestamp": timestamp,
|
|
127
|
+
"result": "error",
|
|
128
|
+
"error_message": str(exc),
|
|
129
|
+
"action": "destroy",
|
|
130
|
+
}
|
|
131
|
+
self.state_repo.write_last_apply(result)
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
def _backup_state(self) -> None:
|
|
135
|
+
state_file = self.config.state_dir / STATE_FILE
|
|
136
|
+
if not state_file.exists():
|
|
137
|
+
return
|
|
138
|
+
backup_dir = self.config.state_dir / BACKUP_DIR
|
|
139
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")
|
|
141
|
+
backup_path = backup_dir / f"{ts}.tfstate"
|
|
142
|
+
shutil.copy2(state_file, backup_path)
|
|
143
|
+
self._prune_backups()
|
|
144
|
+
|
|
145
|
+
def _restore_state(self) -> None:
|
|
146
|
+
backup_dir = self.config.state_dir / BACKUP_DIR
|
|
147
|
+
if not backup_dir.exists():
|
|
148
|
+
return
|
|
149
|
+
backups = sorted(backup_dir.glob("*.tfstate"))
|
|
150
|
+
if not backups:
|
|
151
|
+
return
|
|
152
|
+
latest = backups[-1]
|
|
153
|
+
state_file = self.config.state_dir / STATE_FILE
|
|
154
|
+
shutil.copy2(latest, state_file)
|
|
155
|
+
|
|
156
|
+
def _prune_backups(self) -> None:
|
|
157
|
+
backup_dir = self.config.state_dir / BACKUP_DIR
|
|
158
|
+
backups = sorted(backup_dir.glob("*.tfstate"))
|
|
159
|
+
while len(backups) > self.config.backup_count:
|
|
160
|
+
backups[0].unlink()
|
|
161
|
+
backups = backups[1:]
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tailscale_manager.core.exceptions import TerraformError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_terraform(
|
|
11
|
+
terraform_bin: Path,
|
|
12
|
+
args: list[str],
|
|
13
|
+
cwd: Path,
|
|
14
|
+
env: Mapping[str, str] | None = None,
|
|
15
|
+
timeout: int = 120,
|
|
16
|
+
) -> str:
|
|
17
|
+
cmd = [str(terraform_bin), *args]
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
cmd,
|
|
21
|
+
cwd=str(cwd),
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
timeout=timeout,
|
|
25
|
+
env=env,
|
|
26
|
+
)
|
|
27
|
+
# terraform plan -detailed-exitcode returns:
|
|
28
|
+
# 0 = no changes, 1 = error, 2 = non-empty diff
|
|
29
|
+
# Treat exit code 2 as success for plan (changes to apply).
|
|
30
|
+
if result.returncode != 0 and not (
|
|
31
|
+
len(args) >= 1 and args[0] == "plan" and result.returncode == 2
|
|
32
|
+
):
|
|
33
|
+
msg = (
|
|
34
|
+
f"terraform {' '.join(args)} failed (exit {result.returncode}):\n"
|
|
35
|
+
f"{result.stderr.strip()}"
|
|
36
|
+
)
|
|
37
|
+
raise TerraformError(msg)
|
|
38
|
+
return result.stdout.strip()
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
raise TerraformError(
|
|
41
|
+
f"terraform binary not found at {terraform_bin}"
|
|
42
|
+
)
|
|
43
|
+
except subprocess.TimeoutExpired:
|
|
44
|
+
raise TerraformError(
|
|
45
|
+
f"terraform {' '.join(args)} timed out after {timeout}s"
|
|
46
|
+
)
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tailscale-manager
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: NixOS module + CLI for managing Tailscale auth keys via Terraform
|
|
5
|
+
Author-email: Tailscale Manager <maintainers@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: tailscale,terraform,nixos
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: typer>=0.12
|
|
13
|
+
Requires-Dist: pydantic>=2.5
|
|
14
|
+
Provides-Extra: tui
|
|
15
|
+
Requires-Dist: textual>=0.60; extra == "tui"
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
|
|
18
|
+
<picture>
|
|
19
|
+
<source
|
|
20
|
+
srcset="https://raw.githubusercontent.com/Cairnstew/tailscale-manager/main/assets/logo-dark.svg"
|
|
21
|
+
media="(prefers-color-scheme: dark)"
|
|
22
|
+
/>
|
|
23
|
+
<img
|
|
24
|
+
src="https://raw.githubusercontent.com/Cairnstew/tailscale-manager/main/assets/logo-light.svg"
|
|
25
|
+
alt="tailscale-manager"
|
|
26
|
+
/>
|
|
27
|
+
</picture>
|
|
28
|
+
|
|
29
|
+
# tailscale-manager
|
|
30
|
+
|
|
31
|
+
Declaratively manage Tailscale auth keys via Terraform on NixOS.
|
|
32
|
+
|
|
33
|
+
A NixOS module + Python CLI that wraps the [Tailscale Terraform
|
|
34
|
+
provider](https://registry.terraform.io/providers/tailscale/tailscale)
|
|
35
|
+
to create, rotate, and expire auth keys — all packaged hermetically with
|
|
36
|
+
[uv2nix](https://github.com/pyproject-nix/uv2nix).
|
|
37
|
+
|
|
38
|
+
```console
|
|
39
|
+
$ tailscale-manager status
|
|
40
|
+
Tailscale Manager — your-tailnet.ts.net
|
|
41
|
+
State dir: /var/lib/tailscale-manager
|
|
42
|
+
|
|
43
|
+
Last apply: 2026-05-31T00:00:00+00:00
|
|
44
|
+
Result: ok
|
|
45
|
+
|
|
46
|
+
Terraform state: found
|
|
47
|
+
Managed keys: 1
|
|
48
|
+
✓ k123abc — managed key
|
|
49
|
+
tags: tag:ci
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **Declarative key management** — one `nixos-rebuild switch` to create,
|
|
55
|
+
update, or rotate auth keys. No imperative API calls.
|
|
56
|
+
- **Automatic rotation** — `recreate_if_invalid = "always"` means expired keys
|
|
57
|
+
are replaced automatically on the next apply. No cron, no expiry tracking.
|
|
58
|
+
- **Failure-safe** — tfstate is backed up before every apply. On failure, the
|
|
59
|
+
previous state is restored and the error is written to `last-apply.json`.
|
|
60
|
+
- **Credential watcher** — a systemd path unit re-runs apply when the OAuth
|
|
61
|
+
secret file changes (e.g. after agenix rotation).
|
|
62
|
+
- **Read-only TUI** — optional Textual dashboard showing managed keys and
|
|
63
|
+
system status. No write operations from the UI.
|
|
64
|
+
- **Monitoring-ready** — `tailscale-manager status --json` with exit code
|
|
65
|
+
signaling for waybar, Prometheus node_exporter textfile collector, etc.
|
|
66
|
+
- **Hermetic builds** — full dependency tree locked via `uv.lock` and built
|
|
67
|
+
by Nix. No `pip install` outside of Nix.
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
### 1. Add the flake
|
|
72
|
+
|
|
73
|
+
```nix
|
|
74
|
+
# flake.nix
|
|
75
|
+
{
|
|
76
|
+
inputs = {
|
|
77
|
+
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
78
|
+
tailscale-manager = {
|
|
79
|
+
url = "github:Cairnstew/tailscale-manager";
|
|
80
|
+
inputs.nixpkgs.follows = "nixpkgs";
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
outputs = { self, nixpkgs, tailscale-manager, ... }: {
|
|
85
|
+
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
|
|
86
|
+
modules = [
|
|
87
|
+
tailscale-manager.nixosModules.default
|
|
88
|
+
./configuration.nix
|
|
89
|
+
];
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 2. Create an OAuth client
|
|
96
|
+
|
|
97
|
+
1. Go to https://login.tailscale.com/admin/settings/oauth
|
|
98
|
+
2. Create a client with **all** (read + write) scopes
|
|
99
|
+
3. Under **Tag ownership**, add the tags this client can create keys with
|
|
100
|
+
(e.g. `tag:ci`, `tag:infra`)
|
|
101
|
+
4. Save the client ID and secret
|
|
102
|
+
|
|
103
|
+
### 3. Configure the module
|
|
104
|
+
|
|
105
|
+
```nix
|
|
106
|
+
# configuration.nix
|
|
107
|
+
{ config, ... }: {
|
|
108
|
+
|
|
109
|
+
services.tailscale-manager = {
|
|
110
|
+
enable = true;
|
|
111
|
+
tailnet = "-"; # auto-resolve from OAuth
|
|
112
|
+
credentialsFile = "/run/secrets/tailscale-oauth";
|
|
113
|
+
tags = [ "tag:ci" ];
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 4. Deploy
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
nixos-rebuild switch
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
On first deploy, the service will:
|
|
125
|
+
1. Back up any existing tfstate (none on first run)
|
|
126
|
+
2. Generate `main.tf.json`
|
|
127
|
+
3. Run `terraform init` (downloads the Tailscale provider)
|
|
128
|
+
4. Run `terraform apply` (creates the auth key)
|
|
129
|
+
5. Write the result to `last-apply.json`
|
|
130
|
+
|
|
131
|
+
Every subsequent `nixos-rebuild switch` repeats steps 1–5. If a key has
|
|
132
|
+
expired, `recreate_if_invalid = "always"` causes Terraform to delete it
|
|
133
|
+
and create a new one — **automatic rotation with zero custom logic.**
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## NixOS module reference
|
|
138
|
+
|
|
139
|
+
All options under `services.tailscale-manager`.
|
|
140
|
+
|
|
141
|
+
| Option | Type | Default | Description |
|
|
142
|
+
|---|---|---|---|
|
|
143
|
+
| `enable` | `bool` | `false` | Enable the tailscale-manager service |
|
|
144
|
+
| `tailnet` | `string` | *(required)* | Tailnet name, e.g. `example.com`. Pass `"-"` to auto-resolve from the OAuth credential. |
|
|
145
|
+
| `credentialsFile` | `path` | *(required)* | Path to an EnvironmentFile containing `TAILSCALE_OAUTH_CLIENT_ID` and `TAILSCALE_OAUTH_CLIENT_SECRET`. Encrypt with agenix or sops-nix. |
|
|
146
|
+
| `tags` | `list of strings` | `[]` | Tags to apply to the managed auth key (e.g. `["tag:ci"]`). The OAuth client must own these tags. |
|
|
147
|
+
| `stateDir` | `string` | `/var/lib/tailscale-manager` | Directory for Terraform state and backups |
|
|
148
|
+
| `package` | `package` | `pkgs.tailscale-manager` | Package providing the CLI |
|
|
149
|
+
| `terraformBin` | `path` | `"${pkgs.terraform}/bin/terraform"` | Path to the Terraform binary |
|
|
150
|
+
| `backupCount` | `int` | `5` | Number of tfstate backups to retain in `stateDir/backups/` |
|
|
151
|
+
| `watchCredentials` | `bool` | `true` | Create a systemd path unit that re-runs apply when `credentialsFile` changes |
|
|
152
|
+
|
|
153
|
+
### Systemd units
|
|
154
|
+
|
|
155
|
+
Three units are created when enabled:
|
|
156
|
+
|
|
157
|
+
**`tailscale-manager.service`** — `Type=oneshot`, runs on every
|
|
158
|
+
`nixos-rebuild switch` (via `wantedBy = ["multi-user.target"]`):
|
|
159
|
+
1. Backs up `terraform.tfstate` to `backups/<timestamp>.tfstate`
|
|
160
|
+
2. Prunes old backups to `backupCount`
|
|
161
|
+
3. Generates `main.tf.json`
|
|
162
|
+
4. Runs `terraform init`
|
|
163
|
+
5. Runs `terraform apply -auto-approve`
|
|
164
|
+
6. Writes result to `last-apply.json`
|
|
165
|
+
7. On failure: restores the most recent backup, writes error to
|
|
166
|
+
`last-apply.json`, exits 1 (systemd shows red)
|
|
167
|
+
|
|
168
|
+
**`tailscale-manager-watch.path`** — if `watchCredentials = true`:
|
|
169
|
+
writes the file path changes. Re-triggers the service when
|
|
170
|
+
`credentialsFile` changes via atomic rename (e.g. agenix rotation).
|
|
171
|
+
|
|
172
|
+
**`tailscale-manager.timer`** — placeholder (commented out). Uncomment
|
|
173
|
+
and configure `OnCalendar` for periodic apply if desired.
|
|
174
|
+
|
|
175
|
+
### Activation script
|
|
176
|
+
|
|
177
|
+
After every `nixos-rebuild switch`, the system prints:
|
|
178
|
+
```
|
|
179
|
+
tailscale-manager: last apply [ok]
|
|
180
|
+
```
|
|
181
|
+
or:
|
|
182
|
+
```
|
|
183
|
+
tailscale-manager: last apply [error]
|
|
184
|
+
```
|
|
185
|
+
This is informational only — does not trigger re-apply.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Home Manager module
|
|
190
|
+
|
|
191
|
+
For user-level CLI install without systemd service:
|
|
192
|
+
|
|
193
|
+
```nix
|
|
194
|
+
{ config, ... }: {
|
|
195
|
+
|
|
196
|
+
homeManagerModules.tailscale-manager = {
|
|
197
|
+
enable = true;
|
|
198
|
+
tailnet = "-";
|
|
199
|
+
credentialsFile = "/run/secrets/tailscale-oauth";
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Options: `enable`, `package`, `tailnet`, `credentialsFile`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Credential setup
|
|
209
|
+
|
|
210
|
+
The credentials file must be an EnvironmentFile (KEY=VAL format) containing:
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
TAILSCALE_OAUTH_CLIENT_ID=<your-client-id>
|
|
214
|
+
TAILSCALE_OAUTH_CLIENT_SECRET=<your-client-secret>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### With agenix
|
|
218
|
+
|
|
219
|
+
```nix
|
|
220
|
+
# secrets.nix
|
|
221
|
+
{
|
|
222
|
+
"tailscale-oauth.age".publicKeys = [ <your-host-key> ];
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```nix
|
|
227
|
+
# configuration.nix
|
|
228
|
+
age.secrets.tailscale-oauth = {
|
|
229
|
+
file = ./secrets/tailscale-oauth.age;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
services.tailscale-manager = {
|
|
233
|
+
enable = true;
|
|
234
|
+
tailnet = "-";
|
|
235
|
+
credentialsFile = config.age.secrets.tailscale-oauth.path;
|
|
236
|
+
tags = [ "tag:ci" ];
|
|
237
|
+
};
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The path watcher automatically re-runs apply when agenix rotates the file.
|
|
241
|
+
|
|
242
|
+
### With sops-nix
|
|
243
|
+
|
|
244
|
+
```nix
|
|
245
|
+
sops.secrets.tailscale-oauth = {
|
|
246
|
+
format = "dotenv";
|
|
247
|
+
sopsFile = ./secrets/tailscale-oauth.env;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
services.tailscale-manager = {
|
|
251
|
+
enable = true;
|
|
252
|
+
tailnet = "-";
|
|
253
|
+
credentialsFile = config.sops.secrets.tailscale-oauth.path;
|
|
254
|
+
tags = [ "tag:ci" ];
|
|
255
|
+
};
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## CLI reference
|
|
261
|
+
|
|
262
|
+
```console
|
|
263
|
+
tailscale-manager init # terraform init + provider download
|
|
264
|
+
tailscale-manager plan # terraform plan (shows pending changes)
|
|
265
|
+
tailscale-manager apply # backup → generate → init → apply
|
|
266
|
+
tailscale-manager destroy # backup → terraform destroy
|
|
267
|
+
tailscale-manager status # read-only TUI dashboard
|
|
268
|
+
tailscale-manager status --json # JSON for scripting
|
|
269
|
+
tailscale-manager backup-state # manual tfstate backup
|
|
270
|
+
tailscale-manager restore-state # manual tfstate restore
|
|
271
|
+
tailscale-manager version # show version
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Environment variables
|
|
275
|
+
|
|
276
|
+
| Variable | Required | Default | Description |
|
|
277
|
+
|---|---|---|---|
|
|
278
|
+
| `TAILSCALE_OAUTH_CLIENT_ID` | ✅ | — | Tailscale OAuth client ID |
|
|
279
|
+
| `TAILSCALE_OAUTH_CLIENT_SECRET` | ✅ | — | Tailscale OAuth client secret |
|
|
280
|
+
| `TAILSCALE_TAILNET` | ✅ | — | Tailnet name or `"-"` to auto-resolve |
|
|
281
|
+
| `TAILSCALE_MANAGER_STATE_DIR` | — | `/var/lib/tailscale-manager` | State and backup directory |
|
|
282
|
+
| `TAILSCALE_MANAGER_TERRAFORM_BIN` | — | `terraform` | Terraform binary path |
|
|
283
|
+
| `TAILSCALE_MANAGER_BACKUP_COUNT` | — | `5` | Number of backups to retain |
|
|
284
|
+
| `TAILSCALE_MANAGER_TAGS` | — | `""` | Comma-separated tags, e.g. `tag:ci,tag:infra` |
|
|
285
|
+
|
|
286
|
+
### Exit codes
|
|
287
|
+
|
|
288
|
+
| Command | Exit 0 | Exit 1 |
|
|
289
|
+
|---|---|---|
|
|
290
|
+
| `apply` | Key created/updated | Apply failed (error in `last-apply.json`) |
|
|
291
|
+
| `destroy` | Key destroyed | Destroy failed |
|
|
292
|
+
| `status --json` | Last result was `ok` | Last result was `error` |
|
|
293
|
+
| `plan` | No changes (or changes pending) | Plan failed |
|
|
294
|
+
|
|
295
|
+
Exit code 2 from `terraform plan -detailed-exitcode` (non-empty diff) is
|
|
296
|
+
treated as success — it means there are changes to apply, not an error.
|
|
297
|
+
|
|
298
|
+
### last-apply.json schema
|
|
299
|
+
|
|
300
|
+
Written to `stateDir/last-apply.json` after every apply:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"timestamp": "2026-05-31T00:00:00.000000+00:00",
|
|
305
|
+
"result": "ok"
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
On failure:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"timestamp": "2026-05-31T00:00:00.000000+00:00",
|
|
314
|
+
"result": "error",
|
|
315
|
+
"error_message": "terraform apply ... failed (exit 1):\nError creating tailnet key: ..."
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## Failure handling & recovery
|
|
322
|
+
|
|
323
|
+
```mermaid
|
|
324
|
+
flowchart TD
|
|
325
|
+
A[nixos-rebuild switch] --> B[Backup tfstate]
|
|
326
|
+
B --> C[Generate main.tf.json]
|
|
327
|
+
C --> D[terraform init]
|
|
328
|
+
D --> E[terraform apply]
|
|
329
|
+
E --> F{Success?}
|
|
330
|
+
F -->|Yes| G[Write last-apply.json]
|
|
331
|
+
F -->|No| H[Restore backup]
|
|
332
|
+
H --> I[Write error to last-apply.json]
|
|
333
|
+
I --> J[Exit 1 — systemd shows red]
|
|
334
|
+
G --> K[Exit 0]
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Key guarantees:
|
|
338
|
+
- **Before every mutation**: tfstate is backed up to `backups/<timestamp>.tfstate`
|
|
339
|
+
- **On any failure**: the most recent backup is restored, leaving state exactly
|
|
340
|
+
as it was before the apply
|
|
341
|
+
- **Monitoring surface**: `last-apply.json` is the single source of truth for
|
|
342
|
+
the last operation's result. The TUI, `status --json`, and activation script
|
|
343
|
+
all read from it.
|
|
344
|
+
- **Systemd visibility**: non-zero exit code means `systemctl status
|
|
345
|
+
tailscale-manager` shows red on failure. The error message is in the
|
|
346
|
+
journal and `last-apply.json`.
|
|
347
|
+
|
|
348
|
+
---
|
|
349
|
+
|
|
350
|
+
## Key rotation strategy
|
|
351
|
+
|
|
352
|
+
This project does **not** implement custom key rotation logic. Instead, it
|
|
353
|
+
relies on a single Terraform attribute:
|
|
354
|
+
|
|
355
|
+
```json
|
|
356
|
+
"recreate_if_invalid": "always"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
When a key expires, Terraform detects it as "invalid" and replaces it on the
|
|
360
|
+
next apply — deleting the old resource and creating a new one. This means:
|
|
361
|
+
|
|
362
|
+
- No cron jobs, no expiry date tracking, no manual intervention
|
|
363
|
+
- The rotation happens on the next `nixos-rebuild switch` or credential
|
|
364
|
+
watcher trigger after expiry
|
|
365
|
+
- The key `id` changes (it's a new key), so any system that consumes the
|
|
366
|
+
key value needs to re-read it from Terraform state or the Tailscale admin
|
|
367
|
+
console
|
|
368
|
+
|
|
369
|
+
Key defaults: `reusable = true`, `ephemeral = false`, `preauthorized = true`,
|
|
370
|
+
`expiry` = 90 days (Tailscale default, configurable in the provider).
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## TUI (optional)
|
|
375
|
+
|
|
376
|
+
Install with `uv add textual` or enable the `tui` extra, then run
|
|
377
|
+
`tailscale-manager status`.
|
|
378
|
+
|
|
379
|
+
```
|
|
380
|
+
┌─────────────────────────────────────────┐
|
|
381
|
+
│ Tailscale Manager — your-tailnet.ts.net│
|
|
382
|
+
├────────────────┬────────────────────────┤
|
|
383
|
+
│ KEY STATUS │ SYSTEM STATUS │
|
|
384
|
+
│ │ │
|
|
385
|
+
│ DataTable: │ Last apply: 2026-... │
|
|
386
|
+
│ ✓ k123 — ci │ Result: ✓ ok │
|
|
387
|
+
│ │ Terraform state: found│
|
|
388
|
+
│ │ Credentials: found │
|
|
389
|
+
│ │ Backups: 3 retained │
|
|
390
|
+
│ │ │
|
|
391
|
+
│ │ State dir: /var/lib/..│
|
|
392
|
+
│ │ Tailnet: your-tailnet │
|
|
393
|
+
└────────────────┴────────────────────────┘
|
|
394
|
+
│ Q: Quit R: Refresh L: View Logs │
|
|
395
|
+
└─────────────────────────────────────────┘
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- **Left panel**: DataTable of managed auth keys from local tfstate
|
|
399
|
+
- **Right panel**: System status (last apply, backups, credentials)
|
|
400
|
+
- **Footer**: Q=Quit, R=Refresh (or auto-refresh every 30s), L=View Logs
|
|
401
|
+
(tails `journalctl -u tailscale-manager.service`)
|
|
402
|
+
- **Read-only**: zero write operations from the UI
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Waybar / scripting integration
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"custom/tailscale-manager": {
|
|
411
|
+
"exec": "tailscale-manager status --json",
|
|
412
|
+
"return-type": "json",
|
|
413
|
+
"format": "{}"
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The `status --json` command exits 0 on success, 1 on failure, and outputs:
|
|
419
|
+
|
|
420
|
+
```json
|
|
421
|
+
{
|
|
422
|
+
"last_apply": {
|
|
423
|
+
"timestamp": "2026-05-31T00:00:00+00:00",
|
|
424
|
+
"result": "ok"
|
|
425
|
+
},
|
|
426
|
+
"managed_keys": [
|
|
427
|
+
{
|
|
428
|
+
"id": "k123abc",
|
|
429
|
+
"description": "ci runner key",
|
|
430
|
+
"tags": ["tag:ci"],
|
|
431
|
+
"revoked": false
|
|
432
|
+
}
|
|
433
|
+
]
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
For Prometheus node_exporter textfile collector:
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
#!/bin/sh
|
|
441
|
+
# /etc/periodic/tailscale-manager-metrics
|
|
442
|
+
STATUS=$(tailscale-manager status --json 2>/dev/null) || STATUS='{"result":"error"}'
|
|
443
|
+
RESULT=$(echo "$STATUS" | jq -r '.last_apply.result // "unknown"')
|
|
444
|
+
COUNT=$(echo "$STATUS" | jq '.managed_keys | length')
|
|
445
|
+
cat > /var/lib/node_exporter/textfile/tailscale-manager.prom <<EOF
|
|
446
|
+
# HELP tailscale_manager_last_apply Last apply result (1=ok, 0=error)
|
|
447
|
+
# TYPE tailscale_manager_last_apply gauge
|
|
448
|
+
tailscale_manager_last_apply $([ "$RESULT" = "ok" ] && echo 1 || echo 0)
|
|
449
|
+
# HELP tailscale_manager_managed_keys Number of managed auth keys
|
|
450
|
+
# TYPE tailscale_manager_managed_keys gauge
|
|
451
|
+
tailscale_manager_managed_keys $COUNT
|
|
452
|
+
EOF
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
---
|
|
456
|
+
|
|
457
|
+
## Development
|
|
458
|
+
|
|
459
|
+
```bash
|
|
460
|
+
# Enter the dev environment
|
|
461
|
+
nix develop
|
|
462
|
+
|
|
463
|
+
# Fast environment (lint/typecheck only)
|
|
464
|
+
nix develop .#bootstrap
|
|
465
|
+
|
|
466
|
+
# Add a dependency
|
|
467
|
+
nix develop .#bootstrap
|
|
468
|
+
uv add <package>
|
|
469
|
+
|
|
470
|
+
# Lint
|
|
471
|
+
ruff check src/
|
|
472
|
+
|
|
473
|
+
# Type check
|
|
474
|
+
mypy src/tailscale_manager/
|
|
475
|
+
|
|
476
|
+
# Test
|
|
477
|
+
pytest tests/unit/ -v
|
|
478
|
+
|
|
479
|
+
# Build
|
|
480
|
+
nix build .#default
|
|
481
|
+
|
|
482
|
+
# Full check
|
|
483
|
+
nix flake check
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
See `CONTRIBUTING.md` for pull request workflow.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## Architecture
|
|
491
|
+
|
|
492
|
+
```
|
|
493
|
+
pyproject.toml ──uv add/lock──► uv.lock
|
|
494
|
+
│
|
|
495
|
+
▼
|
|
496
|
+
flake.nix ──workspace.mkPyprojectOverlay──► Nix overlay
|
|
497
|
+
│ │
|
|
498
|
+
│ pyproject-build-systems ───────────────────────┤
|
|
499
|
+
│ │
|
|
500
|
+
└── composeManyExtensions ───────────────────────► pythonSet
|
|
501
|
+
│
|
|
502
|
+
┌───────────┼───────────────────┐
|
|
503
|
+
▼ ▼ ▼
|
|
504
|
+
nix/default.nix nix/devshell.nix nix/module.nix
|
|
505
|
+
(mkApplication) (mkShell) (systemd service)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
The project uses [uv2nix](https://github.com/pyproject-nix/uv2nix) to convert
|
|
509
|
+
`uv.lock` into Nix package derivations. The NixOS module provides the systemd
|
|
510
|
+
service, credential watcher, and activation hook. The Python CLI wraps the
|
|
511
|
+
`terraform` binary — the Tailscale provider does all the actual API work.
|
|
512
|
+
|
|
513
|
+
**Package layers** (import direction rules):
|
|
514
|
+
|
|
515
|
+
```
|
|
516
|
+
src/tailscale_manager/
|
|
517
|
+
├── core/ imports nothing from the package
|
|
518
|
+
├── models/ pure data shapes
|
|
519
|
+
├── services/ imports models/ and repositories/
|
|
520
|
+
├── repositories/ data access (tfstate I/O)
|
|
521
|
+
├── utils/ stateless pure functions
|
|
522
|
+
└── cli.py Typer entrypoint (imports services/)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Common issues
|
|
528
|
+
|
|
529
|
+
- **"tailnet-owned auth key must have tags set"** — the OAuth client needs
|
|
530
|
+
tag ownership configured. See [OAuth tag
|
|
531
|
+
ownership](https://tailscale.com/kb/1215/oauth-clients).
|
|
532
|
+
- **"requested tags are invalid or not permitted"** — same cause. Add the
|
|
533
|
+
tags to the OAuth client's tag ownership list in the admin console.
|
|
534
|
+
- **Provider download fails on first run** — `terraform init` needs outbound
|
|
535
|
+
internet to `registry.terraform.io`. See `GOTCHAS.md` for airgap workarounds.
|
|
536
|
+
- **`terraform` binary not found** — the module sets `terraformBin` to
|
|
537
|
+
`${pkgs.terraform}/bin/terraform` by default. When running the CLI outside
|
|
538
|
+
the NixOS service, ensure terraform is in PATH or set
|
|
539
|
+
`TAILSCALE_MANAGER_TERRAFORM_BIN`.
|
|
540
|
+
|
|
541
|
+
For a full list of gotchas, see [`GOTCHAS.md`](./GOTCHAS.md).
|
|
542
|
+
|
|
543
|
+
---
|
|
544
|
+
|
|
545
|
+
## Related resources
|
|
546
|
+
|
|
547
|
+
- [Tailscale Terraform provider docs](https://registry.terraform.io/providers/tailscale/tailscale)
|
|
548
|
+
- [Tailscale OAuth client docs](https://tailscale.com/kb/1215/oauth-clients)
|
|
549
|
+
- [Tailscale Terraform provider source](https://github.com/tailscale/terraform-provider-tailscale)
|
|
550
|
+
- [uv2nix docs](https://pyproject-nix.github.io/uv2nix/)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
tailscale_manager/__init__.py,sha256=hY2Q7RAVyihDYp28CzQnwMZErjdXvu3jU7nxsuE5B-c,89
|
|
2
|
+
tailscale_manager/cli.py,sha256=o8kznIQNs5p4F6iAkRGw-FTm7EiSQadEHVYWgIaUqPU,4224
|
|
3
|
+
tailscale_manager/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
tailscale_manager/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
tailscale_manager/core/config.py,sha256=5a-N2d0DGHo9aDFG_BVsov4I442zJwqXs3PCOyRgQuM,2005
|
|
6
|
+
tailscale_manager/core/constants.py,sha256=BsscnytMNAfcvQD84UCCvvIXPGOQ3diY3ynjO2ZvRkk,314
|
|
7
|
+
tailscale_manager/core/exceptions.py,sha256=623cWjre89KBKGdGGdaUIZpCIcemjwr6Qyl7lndHHEo,271
|
|
8
|
+
tailscale_manager/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
tailscale_manager/models/auth_key.py,sha256=JQwQAxG91LEdPW4_BSVDaSSbcV_F2udeAey6dDOUFmc,439
|
|
10
|
+
tailscale_manager/repositories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
tailscale_manager/repositories/state_repository.py,sha256=aUiqKuTa2sqdaSnPb7hOptTJ8PEffMMQRMCX3fgwjU8,2351
|
|
12
|
+
tailscale_manager/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
tailscale_manager/services/terraform_service.py,sha256=OjjwlazXpEAhd7FbQOPeip-j3R-KzuhxykNH7nLGF5g,5031
|
|
14
|
+
tailscale_manager/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
tailscale_manager/utils/subprocess_helpers.py,sha256=JDYbYS4QF_qq5qoM3uby8Ezx0ySu9WzVK2T_TofCkb8,1397
|
|
16
|
+
textual_ui/__init__.py,sha256=4eTXG8M8e8rOYlY2bUFZkfQ3g0S7BgMCIHpaa7CeKyY,116
|
|
17
|
+
textual_ui/app.py,sha256=c5z0WwGCJKn37uQ3kE5UXjJFDhT3ZCnv7Sx5ZnSNXPk,5897
|
|
18
|
+
textual_ui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
19
|
+
tailscale_manager-0.1.0.dist-info/METADATA,sha256=JRwq_b2tpwaNmoLtRdOMwvjjLGQGomgiU-XZhZcWP14,17595
|
|
20
|
+
tailscale_manager-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
21
|
+
tailscale_manager-0.1.0.dist-info/entry_points.txt,sha256=KOyFwHQJy6hwvOv29J1s1cZg2IFKnhAIDYupykTdVq8,64
|
|
22
|
+
tailscale_manager-0.1.0.dist-info/top_level.txt,sha256=RErty97X5XN6v1YYarg1pcO4C4rnUeqlBS-cPW2DeZc,29
|
|
23
|
+
tailscale_manager-0.1.0.dist-info/RECORD,,
|
textual_ui/__init__.py
ADDED
textual_ui/app.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from textual.app import App as TextualAppBase
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.containers import Horizontal, Vertical
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import DataTable, Footer, Header, Static, TextArea
|
|
11
|
+
|
|
12
|
+
from tailscale_manager.core.config import AppConfig
|
|
13
|
+
from tailscale_manager.models.auth_key import TailscaleAuthKey
|
|
14
|
+
from tailscale_manager.repositories.state_repository import StateRepository
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_status_app(
|
|
18
|
+
config: AppConfig,
|
|
19
|
+
keys: list[TailscaleAuthKey],
|
|
20
|
+
last_apply: dict | None,
|
|
21
|
+
) -> None:
|
|
22
|
+
app = TailscaleManagerApp(config, keys, last_apply)
|
|
23
|
+
app.run()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SystemStatus(Static):
|
|
27
|
+
config: AppConfig
|
|
28
|
+
last_apply: dict | None
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
config: AppConfig,
|
|
33
|
+
last_apply: dict | None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.config = config
|
|
37
|
+
self.last_apply = last_apply
|
|
38
|
+
|
|
39
|
+
def on_mount(self) -> None:
|
|
40
|
+
self.refresh_content()
|
|
41
|
+
|
|
42
|
+
def refresh_content(self) -> None:
|
|
43
|
+
repo = StateRepository(self.config.state_dir)
|
|
44
|
+
last = repo.read_last_apply()
|
|
45
|
+
if last:
|
|
46
|
+
self.last_apply = last
|
|
47
|
+
|
|
48
|
+
lines: list[str] = []
|
|
49
|
+
lines.append("[bold]System Status[/bold]")
|
|
50
|
+
lines.append("")
|
|
51
|
+
|
|
52
|
+
if self.last_apply:
|
|
53
|
+
ts = self.last_apply.get("timestamp", "unknown")
|
|
54
|
+
result = self.last_apply.get("result", "unknown")
|
|
55
|
+
icon = "✓" if result == "ok" else "✗"
|
|
56
|
+
ts_str = str(ts)[:19] if not isinstance(ts, str) else ts[:19]
|
|
57
|
+
lines.append(f"Last apply: {ts_str}")
|
|
58
|
+
lines.append(f" Result: {icon} {result}")
|
|
59
|
+
err = self.last_apply.get("error_message")
|
|
60
|
+
if err:
|
|
61
|
+
lines.append(f" Error: {str(err)[:80]}")
|
|
62
|
+
else:
|
|
63
|
+
lines.append("Last apply: never")
|
|
64
|
+
|
|
65
|
+
lines.append("")
|
|
66
|
+
state_file = self.config.state_dir / "terraform.tfstate"
|
|
67
|
+
tf_found = "found" if state_file.exists() else "not found"
|
|
68
|
+
lines.append(f"Terraform state: {tf_found}")
|
|
69
|
+
|
|
70
|
+
has_id = bool(os.environ.get("TAILSCALE_OAUTH_CLIENT_ID"))
|
|
71
|
+
has_secret = bool(os.environ.get("TAILSCALE_OAUTH_CLIENT_SECRET"))
|
|
72
|
+
creds_status = "found" if has_id and has_secret else "not set"
|
|
73
|
+
lines.append(f"Credentials: {creds_status}")
|
|
74
|
+
|
|
75
|
+
backup_dir = self.config.state_dir / "backups"
|
|
76
|
+
if backup_dir.exists():
|
|
77
|
+
bcount = len(list(backup_dir.glob("*.tfstate")))
|
|
78
|
+
else:
|
|
79
|
+
bcount = 0
|
|
80
|
+
lines.append(f"Backups: {bcount} retained")
|
|
81
|
+
|
|
82
|
+
lines.append("")
|
|
83
|
+
lines.append(f"State dir: {self.config.state_dir}")
|
|
84
|
+
lines.append(f"Tailnet: {self.config.tailnet}")
|
|
85
|
+
self.update("\n".join(lines))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LogViewer(Screen):
|
|
89
|
+
def compose(self) -> ComposeResult:
|
|
90
|
+
yield Header()
|
|
91
|
+
yield TextArea("Loading logs...", read_only=True)
|
|
92
|
+
yield Footer()
|
|
93
|
+
|
|
94
|
+
BINDINGS = [("escape", "dismiss", "Back")]
|
|
95
|
+
|
|
96
|
+
def on_mount(self) -> None:
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run(
|
|
99
|
+
["journalctl", "-u", "tailscale-manager.service", "--no-pager", "-n", "30"],
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=10,
|
|
103
|
+
)
|
|
104
|
+
content = result.stdout or "No logs found"
|
|
105
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
106
|
+
content = "Unable to fetch logs"
|
|
107
|
+
ta = self.query_one(TextArea)
|
|
108
|
+
ta.text = content
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TailscaleManagerApp(TextualAppBase):
|
|
112
|
+
CSS = """
|
|
113
|
+
Screen {
|
|
114
|
+
layout: horizontal;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.left-panel {
|
|
118
|
+
width: 60%;
|
|
119
|
+
height: 100%;
|
|
120
|
+
border: solid $primary;
|
|
121
|
+
padding: 0 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.right-panel {
|
|
125
|
+
width: 40%;
|
|
126
|
+
height: 100%;
|
|
127
|
+
border: solid $primary;
|
|
128
|
+
padding: 0 1;
|
|
129
|
+
}
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
BINDINGS = [
|
|
133
|
+
("q", "quit", "Quit"),
|
|
134
|
+
("r", "refresh", "Refresh"),
|
|
135
|
+
("l", "view_logs", "View Logs"),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
def __init__(
|
|
139
|
+
self,
|
|
140
|
+
config: AppConfig,
|
|
141
|
+
keys: list[TailscaleAuthKey],
|
|
142
|
+
last_apply: dict | None,
|
|
143
|
+
) -> None:
|
|
144
|
+
super().__init__()
|
|
145
|
+
self.app_config = config
|
|
146
|
+
self.initial_keys = keys
|
|
147
|
+
self.initial_last_apply = last_apply
|
|
148
|
+
|
|
149
|
+
def compose(self) -> ComposeResult:
|
|
150
|
+
yield Header()
|
|
151
|
+
with Horizontal():
|
|
152
|
+
with Vertical(classes="left-panel"):
|
|
153
|
+
yield DataTable(id="keys-table")
|
|
154
|
+
with Vertical(classes="right-panel"):
|
|
155
|
+
yield SystemStatus(self.app_config, self.initial_last_apply)
|
|
156
|
+
yield Footer()
|
|
157
|
+
|
|
158
|
+
def on_mount(self) -> None:
|
|
159
|
+
self.title = f"Tailscale Manager — {self.app_config.tailnet}"
|
|
160
|
+
self._populate_table(self.initial_keys)
|
|
161
|
+
self.set_interval(30, self.action_refresh)
|
|
162
|
+
|
|
163
|
+
def _populate_table(self, keys: list[TailscaleAuthKey]) -> None:
|
|
164
|
+
table = self.query_one("#keys-table", DataTable)
|
|
165
|
+
table.clear(columns=True)
|
|
166
|
+
table.add_columns("ID", "Description", "Tags", "Expiry", "Status")
|
|
167
|
+
for k in keys:
|
|
168
|
+
status = "✓" if not k.revoked else "✗"
|
|
169
|
+
expiry = k.expiry.strftime("%Y-%m-%d") if k.expiry else "-"
|
|
170
|
+
tags = ", ".join(k.tags) if k.tags else "-"
|
|
171
|
+
table.add_row(
|
|
172
|
+
k.id[:16] if k.id else "-",
|
|
173
|
+
k.description or "-",
|
|
174
|
+
tags,
|
|
175
|
+
expiry,
|
|
176
|
+
status,
|
|
177
|
+
)
|
|
178
|
+
if not keys:
|
|
179
|
+
table.add_row("(no keys managed)", "", "", "", "")
|
|
180
|
+
|
|
181
|
+
def action_refresh(self) -> None:
|
|
182
|
+
repo = StateRepository(self.app_config.state_dir)
|
|
183
|
+
keys = repo.get_managed_keys()
|
|
184
|
+
self._populate_table(keys)
|
|
185
|
+
sys_panel = self.query_one(SystemStatus)
|
|
186
|
+
sys_panel.refresh_content()
|
|
187
|
+
|
|
188
|
+
def action_view_logs(self) -> None:
|
|
189
|
+
self.push_screen(LogViewer())
|
textual_ui/py.typed
ADDED
|
File without changes
|