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.
@@ -0,0 +1,3 @@
1
+ from tailscale_manager.core.exceptions import TemplateError
2
+
3
+ __all__ = ["TemplateError"]
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tailscale-manager = tailscale_manager.cli:app
@@ -0,0 +1,2 @@
1
+ tailscale_manager
2
+ textual_ui
textual_ui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from textual_ui.app import TailscaleManagerApp, run_status_app
2
+
3
+ __all__ = ["TailscaleManagerApp", "run_status_app"]
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