|
| 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