tailscale-manager 0.1.0__tar.gz
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-0.1.0/PKG-INFO +550 -0
- tailscale_manager-0.1.0/README.md +533 -0
- tailscale_manager-0.1.0/pyproject.toml +68 -0
- tailscale_manager-0.1.0/setup.cfg +4 -0
- tailscale_manager-0.1.0/src/tailscale_manager/__init__.py +3 -0
- tailscale_manager-0.1.0/src/tailscale_manager/cli.py +165 -0
- tailscale_manager-0.1.0/src/tailscale_manager/core/__init__.py +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/core/config.py +69 -0
- tailscale_manager-0.1.0/src/tailscale_manager/core/constants.py +11 -0
- tailscale_manager-0.1.0/src/tailscale_manager/core/exceptions.py +21 -0
- tailscale_manager-0.1.0/src/tailscale_manager/models/__init__.py +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/models/auth_key.py +18 -0
- tailscale_manager-0.1.0/src/tailscale_manager/py.typed +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/repositories/__init__.py +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/repositories/state_repository.py +65 -0
- tailscale_manager-0.1.0/src/tailscale_manager/services/__init__.py +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/services/terraform_service.py +161 -0
- tailscale_manager-0.1.0/src/tailscale_manager/utils/__init__.py +0 -0
- tailscale_manager-0.1.0/src/tailscale_manager/utils/subprocess_helpers.py +46 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/PKG-INFO +550 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/SOURCES.txt +26 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/dependency_links.txt +1 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/entry_points.txt +2 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/requires.txt +7 -0
- tailscale_manager-0.1.0/src/tailscale_manager.egg-info/top_level.txt +2 -0
- tailscale_manager-0.1.0/src/textual_ui/__init__.py +3 -0
- tailscale_manager-0.1.0/src/textual_ui/app.py +189 -0
- tailscale_manager-0.1.0/src/textual_ui/py.typed +0 -0
|
@@ -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/)
|