Skip to content

Commit 5ceb01c

Browse files
committed
docs: add CLI redesign proposal
1 parent 0483085 commit 5ceb01c

1 file changed

Lines changed: 293 additions & 0 deletions

File tree

docs/proposals/001-cli.md

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
# Headscale CLI redesign
2+
3+
**Status:** draft, for discussion. This PR exists so we can shape the design together — it is not meant to be merged as-is.
4+
5+
The CLI has grown organically and it shows. Targets are addressed inconsistently: nodes take `--identifier` (ID only), users take `--identifier` or `--name`, API keys take `--id` or `--prefix`. Positional arguments are used in some places (`nodes rename NEW_NAME` takes the _new_ name positionally, but the target node via a flag) and flags in others. `nodes register` is deprecated in favour of `auth register` but still present. Tables have a fixed set of columns. This proposal is a ground-up restructure of the command tree and its conventions.
6+
7+
## Requirements
8+
9+
- `--force` override for all commands requiring prompts
10+
- `--output` on every command offering `json`, `json-line` / `jsonl`, drop `yaml`
11+
- `--help` auto generated for all commands with good documentation
12+
- usage examples
13+
- good command enumeration and descriptions
14+
- the full nested command tree must be presented so people can find commands
15+
- Any irreversible command should ask for confirm (or `--force`): `delete`, `expire`, `reject`
16+
- In principle, the entire API should be covered by the CLI. Any operation that can be done with the API should be available. Each entity in Headscale should have CRUD where it makes sense.
17+
18+
## Current root
19+
20+
```
21+
❯ headscale --help
22+
23+
headscale is an open source implementation of the Tailscale control server
24+
25+
https://github.com/juanfont/headscale
26+
27+
Usage:
28+
headscale [command]
29+
30+
Available Commands:
31+
apikeys Handle the Api keys in Headscale
32+
auth Manage node authentication and approval
33+
completion Generate the autocompletion script for the specified shell
34+
configtest Test the configuration.
35+
debug debug and testing commands
36+
generate Generate commands
37+
health Check the health of the Headscale server
38+
help Help about any command
39+
mockoidc Runs a mock OIDC server for testing
40+
nodes Manage the nodes of Headscale
41+
policy Manage the Headscale ACL Policy
42+
preauthkeys Handle the preauthkeys in Headscale
43+
serve Launches the headscale server
44+
users Manage the users of Headscale
45+
version Print the version.
46+
```
47+
48+
## New structure
49+
50+
```
51+
headscale
52+
serve
53+
version
54+
config
55+
validate
56+
generate-private-key
57+
58+
# `auth` is the authentication of nodes to headscale
59+
auth
60+
# resource
61+
key # preauthkey management
62+
63+
# actions
64+
approve # approve a pending authentication request
65+
reject # reject a pending authentication request
66+
register # register a node via web auth
67+
68+
# `api` is the authentication towards headscale
69+
api
70+
key # api key management
71+
oauth2 # oauth2 client management
72+
73+
# These are resources-ish
74+
nodes
75+
users
76+
policy
77+
check
78+
get
79+
set
80+
81+
debug
82+
health
83+
create-node
84+
mockoidc
85+
```
86+
87+
Commands that go away:
88+
89+
- `nodes backfillips` — dropped entirely.
90+
- `nodes register` — already deprecated, replaced by `auth register`.
91+
- `configtest` and `dumpConfig` — folded into `config validate`.
92+
- `generate private-key` — becomes `config generate-private-key`.
93+
94+
## Positional targets
95+
96+
The "primary" object/target should be a positional argument. When a command targets something, the argument comes after the operation and resolves to the target. Flags are for additional and optional things like output format or filtering.
97+
98+
For example, renaming a node:
99+
100+
```
101+
$ headscale nodes rename <target node identifier> new-hostname
102+
103+
# Using Node ID to resolve the node
104+
$ headscale nodes rename 42 new-hostname
105+
106+
# Using Node name to resolve the node
107+
$ headscale nodes rename old-hostname new-hostname
108+
```
109+
110+
All list arguments are comma separated lists.
111+
112+
## Resolvers
113+
114+
Every type that can be addressed in multiple ways gets a resolver that always resolves to exactly one target, or errors. A resolver never guesses: zero matches is an error, more than one match is an error listing the candidates so the user can retry with the ID.
115+
116+
The resolver checks identifiers in a fixed order, most specific first:
117+
118+
1. Is it an ID and does it resolve? → return that target.
119+
1. Is it a name (or the other supported fields below) and does it resolve?
120+
1. Resolves to one target → return it.
121+
1. Resolves to multiple → error with the list of candidates.
122+
1. Nothing matched → "not found" error.
123+
124+
It should not support every field, but a small, predictable set per type:
125+
126+
| Type | Accepted identifiers |
127+
| --------------------------- | ------------------------------------------- |
128+
| Node | ID, hostname, given name |
129+
| User | ID, email, username, OIDC unique identifier |
130+
| Keys (preauth, api, oauth2) | ID, key prefix |
131+
132+
Ambiguity looks like this:
133+
134+
```
135+
$ headscale nodes delete node
136+
Error: "node" matches multiple nodes, use the ID to select one:
137+
138+
ID | Hostname | User
139+
12 | node | kradalby
140+
13 | node | juanfont
141+
```
142+
143+
Because IDs are checked first, a node that is literally _named_ "12" cannot be selected by name if a node with ID 12 exists — use its ID instead. This is the price of never guessing, and IDs-first keeps scripts stable.
144+
145+
Preauth keys are today only addressable by full key or numeric ID. To make `<id or prefix>` work everywhere, keys get a short unique prefix that can be shown in listings and used as a target, so nobody has to paste a full secret into their shell history.
146+
147+
## More examples
148+
149+
```
150+
# headscale has a node named "mynode" with ID 12.
151+
152+
headscale nodes delete mynode # prompts for confirmation
153+
headscale nodes delete 12 --force # skips confirmation
154+
155+
headscale nodes tags 12 tag:test,tag:example
156+
157+
headscale nodes set-expiry 12 2025-08-27T10:00:00Z # support more formats
158+
headscale nodes set-expiry 12 now
159+
headscale nodes expire 12 # same as set-expiry now
160+
headscale nodes disable-expiry 12
161+
```
162+
163+
An alternative shape is a top-level `expire` group instead of `nodes set-expiry`. We want reviewer input on which to pick:
164+
165+
```
166+
headscale expire set <node id> <expiry time>
167+
headscale expire set 12 2025-08-27T10:00:00Z # support more formats
168+
headscale expire set 12 30m
169+
headscale expire set 12 now
170+
headscale expire now 12 # same as set now
171+
headscale expire set 12 never # same as disable
172+
headscale expire disable 12
173+
```
174+
175+
The same question applies to routes and tags — top-level groups, or under `nodes`:
176+
177+
```
178+
headscale routes list # lists all
179+
headscale routes list 12
180+
headscale routes approve <node id> <route list>
181+
headscale routes approve 12 10.0.0.0/8,192.168.0.0/24
182+
headscale routes approve 12 "" # remove all from 12
183+
headscale routes disapprove-all 12 # remove all from 12
184+
185+
headscale tags list # list all tags, show node associated with tag?
186+
headscale tags list <node id> # list tags of node
187+
headscale tags set <node id> <tag list>
188+
headscale tags set 12 tag:example,tag:test
189+
```
190+
191+
Users, auth, policy and oauth2:
192+
193+
```
194+
headscale users list
195+
headscale users create <name> # optional fields are flags
196+
headscale users set <name> # optional fields are flags
197+
headscale users rename <old-name> <new-name>
198+
headscale users delete <name>
199+
200+
headscale auth approve <id or prefix>
201+
headscale auth register <id or prefix> <user>
202+
headscale auth reject <id or prefix>
203+
204+
headscale auth key list # list all auth keys
205+
headscale auth key create <user or tag> # [^1], optionals are flags
206+
headscale auth key revoke <id or prefix>
207+
headscale auth key delete <id or prefix>
208+
209+
headscale policy check # reads from database or file path from config
210+
headscale policy check <file path>
211+
headscale policy get
212+
headscale policy set <file path>
213+
214+
headscale api oauth list # list all oauth clients
215+
headscale api oauth create <scope list> <tag list>
216+
headscale api oauth create devices:core,devices:routes tag:example
217+
headscale api oauth create devices:core:read tag:example -d "description"
218+
headscale api oauth revoke <id or prefix>
219+
headscale api oauth delete <id or prefix>
220+
```
221+
222+
## Outputs
223+
224+
We will support outputting `json`, `json-line` and a human readable table. Default output mode is always the human readable table. `json` formats will always show all data. `yaml` is dropped.
225+
226+
We looked at `kubectl` as prior art for the sections below. Worth knowing: kubectl's default table columns are decided _server side_ (the API server returns a rendered table), while `custom-columns`, `jsonpath` and `--sort-by` are all applied client side over the full objects. Headscale's lists are small, so everything below is client side; the CLI fetches full objects and formats locally.
227+
228+
### Dynamic table `--columns`
229+
230+
Currently the table has a fixed set of columns that the user can not change. In the new version, the table is configurable and renders only the requested columns. There is a default selection if no `--columns` argument is passed, so the default behaviour stays more or less as today.
231+
232+
```
233+
headscale nodes list --columns id,hostname,online,last-seen
234+
```
235+
236+
Column names are simple, stable identifiers matching the table headers — no `HEADER:.json.path` pairs like kubectl's `custom-columns`. Headscale's entities are flat enough that paths add faff without value. The same names are used by `--filter`, so learning one vocabulary covers both.
237+
238+
The implementation must be generic and work across all CLI commands and types with little faff: one table renderer, each type declaring its columns (name, default on/off, how to render the value) once.
239+
240+
### Filtering `--filter`
241+
242+
The human readable mode supports a `--filter` flag which filters rows out of the table before it is rendered. The filter uses the same names as the columns, for example `--filter=hostname:node0`, returning all nodes with `node0` in their hostname. Multiple arguments are allowed and filtered from left to right: `--filter=hostname:node,online:true`.
243+
244+
Matching modes are selected with `--filter-mode`:
245+
246+
- `contains` (default) — substring match, as in the examples above
247+
- `prefix` — match from the start of the value
248+
- `fuzzy` — fzf-style fuzzy matching, powered by [sahilm/fuzzy](https://github.com/sahilm/fuzzy) (stdlib-only, MIT; importing fzf's own algorithm package works but it is an app-internal API that drags fzf's TUI dependencies into go.sum)
249+
250+
For what it's worth, kubectl has no client-side row filter at all — its docs tell you to pipe to `jq` or `grep`, and its server-side field selectors are a hardcoded allowlist per resource. We are deliberately filling that gap, because it is cheap for us and useful daily.
251+
252+
Filtering is client side for now. Whether the list APIs should grow filter parameters so API users get the same capability is an open question — it would need checking against Tailscale API compatibility.
253+
254+
### jsonpath output
255+
256+
For extracting values from JSON output, `--output` accepts a jsonpath mode, kubectl-style:
257+
258+
```
259+
headscale nodes list --output jsonpath='$.nodes[*].name'
260+
```
261+
262+
Unlike kubectl — whose dialect predates the standard and is implemented in its own template engine (`{range}`/`{end}` and friends) — we use [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535), the standardised JSONPath, via [theory/jsonpath](https://github.com/theory/jsonpath) (MIT, actively maintained, full RFC compliance). This meets the bar of getting it "mostly for free" from a library: expressions users write are the standard ones documented everywhere, and the maintenance cost on our side is wiring one library into the output path.
263+
264+
The implementation must be generic and work across all CLI commands: jsonpath applies to the same JSON the `json` output mode produces.
265+
266+
## Informative feedback
267+
268+
We should focus on success and error messaging that is meaningful to humans.
269+
270+
For example, if a user sets the expiry of a node 2h30m into the future:
271+
272+
```
273+
$ headscale expire set 12 2025-08-27T10:00:00Z
274+
Node 12 (mynode) will now expire in 2h30m (2025-08-27 10:00:00 UTC)
275+
```
276+
277+
Commands which require confirmation should include all the information the user needs to make the correct decision:
278+
279+
```
280+
$ headscale nodes delete 12
281+
Are you sure you want to delete Node 12 (mynode), owned by kradalby? [y/N]
282+
```
283+
284+
Errors follow the same rule — say what was looked up, what was found, and what to do about it (see the resolver ambiguity example above).
285+
286+
## Open questions
287+
288+
- `expire`, `routes`, `tags`: top-level command groups or subcommands under `nodes`?
289+
- Should `--filter` also be implemented server side so API users benefit too, and does that conflict with staying compatible with Tailscale's API?
290+
- Preauth keys need a short prefix to be addressable without the full secret — confirm we're happy adding that.
291+
- Should `headscale tags list` show which nodes are associated with each tag?
292+
293+
[^1]: A preauth key is owned by either a user or a tag, never both, matching how nodes are owned.

0 commit comments

Comments
 (0)