From ddaa04f573a9f4dc6186a228b31a854795dd5b54 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 9 Jul 2026 15:35:46 +0000 Subject: [PATCH] docs: add CLI redesign proposal --- docs/proposals/001-cli.md | 283 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/proposals/001-cli.md diff --git a/docs/proposals/001-cli.md b/docs/proposals/001-cli.md new file mode 100644 index 000000000..11f63c79d --- /dev/null +++ b/docs/proposals/001-cli.md @@ -0,0 +1,283 @@ +# Headscale CLI redesign + +**Status:** draft, for discussion. This PR exists so we can shape the design together — it is not meant to be merged as-is. + +The current CLI addresses targets inconsistently: nodes take `--identifier` (ID only), users take `--identifier` or `--name`, API keys take `--id` or `--prefix`. Positional arguments are only used in a few places. `nodes register` is deprecated in favour of `auth register` but still around. Tables have a fixed set of columns. This proposal restructures the command tree and its conventions. + +## Requirements + +- `--force` override for all commands requiring prompts +- `--output` on every command offering `json`, `json-line` / `jsonl`, drop `yaml` +- `--help` auto generated for all commands with good documentation + - usage examples + - good command enumeration and descriptions + - the full nested command tree must be presented so people can find commands +- Any irreversible command should ask for confirm (or `--force`): `delete`, `expire`, `reject` +- 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. + +## Current root + +``` +❯ headscale --help + +headscale is an open source implementation of the Tailscale control server + +https://github.com/juanfont/headscale + +Usage: + headscale [command] + +Available Commands: + apikeys Handle the Api keys in Headscale + auth Manage node authentication and approval + completion Generate the autocompletion script for the specified shell + configtest Test the configuration. + debug debug and testing commands + generate Generate commands + health Check the health of the Headscale server + help Help about any command + mockoidc Runs a mock OIDC server for testing + nodes Manage the nodes of Headscale + policy Manage the Headscale ACL Policy + preauthkeys Handle the preauthkeys in Headscale + serve Launches the headscale server + users Manage the users of Headscale + version Print the version. +``` + +## New structure + +``` +headscale + serve + version + config + validate + generate-private-key + + # `auth` is the authentication of nodes to headscale + auth + # resource + key # preauthkey management + + # actions + approve # approve a pending authentication request + reject # reject a pending authentication request + register # register a node via web auth + + # `api` is the authentication towards headscale + api + key # api key management + oauth2 # oauth2 client management + + # These are resources-ish + nodes + users + policy + check + get + set + + debug + health + create-node + mockoidc +``` + +Commands that go away: + +- `nodes backfillips` — dropped entirely. +- `nodes register` — already deprecated, replaced by `auth register`. +- `configtest` and `dumpConfig` — folded into `config validate`. +- `generate private-key` — becomes `config generate-private-key`. + +## Positional targets + +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. + +For example, renaming a node: + +``` +$ headscale nodes rename new-hostname + +# Using Node ID to resolve the node +$ headscale nodes rename 42 new-hostname + +# Using Node name to resolve the node +$ headscale nodes rename old-hostname new-hostname +``` + +All list arguments are comma separated lists. + +## Resolvers + +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. + +The resolver checks identifiers in a fixed order, most specific first: + +1. Is it an ID and does it resolve? → return that target. +1. Is it a name (or the other supported fields below) and does it resolve? + 1. Resolves to one target → return it. + 1. Resolves to multiple → error with the list of candidates. +1. Nothing matched → "not found" error. + +It should not support every field, but a small, predictable set per type: + +| Type | Accepted identifiers | +| --------------------------- | ------------------------------------------- | +| Node | ID, hostname, given name | +| User | ID, email, username, OIDC unique identifier | +| Keys (preauth, api, oauth2) | ID, key prefix | + +Ambiguity looks like this: + +``` +$ headscale nodes delete node +Error: "node" matches multiple nodes, use the ID to select one: + + ID | Hostname | User + 12 | node | kradalby + 13 | node | juanfont +``` + +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. + +Preauth keys are today only addressable by full key or numeric ID. To make `` work everywhere, keys get a short unique prefix that is shown in listings and can be used as a target. + +## More examples + +``` +# headscale has a node named "mynode" with ID 12. + +headscale nodes delete mynode # prompts for confirmation +headscale nodes delete 12 --force # skips confirmation + +headscale nodes tags 12 tag:test,tag:example + +headscale nodes set-expiry 12 2025-08-27T10:00:00Z # support more formats +headscale nodes set-expiry 12 now +headscale nodes expire 12 # same as set-expiry now +headscale nodes disable-expiry 12 +``` + +An alternative shape is a top-level `expire` group instead of `nodes set-expiry`. We want reviewer input on which to pick: + +``` +headscale expire set +headscale expire set 12 2025-08-27T10:00:00Z # support more formats +headscale expire set 12 30m +headscale expire set 12 now +headscale expire now 12 # same as set now +headscale expire set 12 never # same as disable +headscale expire disable 12 +``` + +The same question applies to routes and tags — top-level groups, or under `nodes`: + +``` +headscale routes list # lists all +headscale routes list 12 +headscale routes approve +headscale routes approve 12 10.0.0.0/8,192.168.0.0/24 +headscale routes approve 12 "" # remove all from 12 +headscale routes disapprove-all 12 # remove all from 12 + +headscale tags list # list all tags, show node associated with tag? +headscale tags list # list tags of node +headscale tags set +headscale tags set 12 tag:example,tag:test +``` + +Users, auth, policy and oauth2: + +``` +headscale users list +headscale users create # optional fields are flags +headscale users set # optional fields are flags +headscale users rename +headscale users delete + +headscale auth approve +headscale auth register +headscale auth reject + +headscale auth key list # list all auth keys +headscale auth key create # [^1], optionals are flags +headscale auth key revoke +headscale auth key delete + +headscale policy check # reads from database or file path from config +headscale policy check +headscale policy get +headscale policy set + +headscale api oauth list # list all oauth clients +headscale api oauth create +headscale api oauth create devices:core,devices:routes tag:example +headscale api oauth create devices:core:read tag:example -d "description" +headscale api oauth revoke +headscale api oauth delete +``` + +## Outputs + +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. + +### Dynamic table `--columns` + +Currently the table has a fixed set of columns that the user can not change. In the new version, we want the table to be configurable where it will only render the requested columns. There will be a default selection if no `--columns` argument is passed so it has more or less the current behaviour for default columns. + +``` +headscale nodes list --columns id,hostname,online,last-seen +``` + +Column names are plain and match the table headers. kubectl uses `HEADER:.json.path` pairs for its `custom-columns`; our types are flat, so we do not need paths. `--filter` uses the same names. + +The implementation must be generic and work across all CLI commands and types with little faff. + +### Filtering `--filter` + +The human readable mode supports a `--filter` flag which allows the user to filter out lines from 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`. + +`--filter-mode` selects how values are matched: `contains` (default), `prefix`, or `fuzzy`. Fuzzy matching is powered by [sahilm/fuzzy](https://github.com/sahilm/fuzzy) — importing fzf's own algorithm package works, but it is an app-internal API and drags fzf's TUI dependencies into go.sum. + +kubectl has no client-side filter — its docs point at `jq` and `grep`, and its server-side field selectors are a hardcoded allowlist per resource. Our lists are small, so we filter client side. Whether the list APIs should also grow filter parameters so API users get the same thing is an open question — it needs checking against Tailscale API compatibility. + +The implementation must be generic and work across all CLI commands and types with little faff. + +### jsonpath output + +For JSON output, `--output` also accepts a jsonpath expression: + +``` +headscale nodes list --output jsonpath='$.nodes[*].name' +``` + +kubectl's jsonpath is its own dialect from before the standard existed. We use [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535) instead, via [theory/jsonpath](https://github.com/theory/jsonpath) (MIT, actively maintained). That meets the bar of getting it mostly for free: one library wired into the output path, and the expressions are the standard ones documented everywhere. + +## Informative feedback + +We should focus on success and error messaging that is meaningful to humans. + +For example, if a user sets the expiry of a node 2h30m into the future: + +``` +$ headscale expire set 12 2025-08-27T10:00:00Z +Node 12 (mynode) will now expire in 2h30m (2025-08-27 10:00:00 UTC) +``` + +Commands which require confirmation should include all the information the user needs to make the correct decision: + +``` +$ headscale nodes delete 12 +Are you sure you want to delete Node 12 (mynode), owned by kradalby? [y/N] +``` + +## Open questions + +- `expire`, `routes`, `tags`: top-level command groups or subcommands under `nodes`? +- Should `--filter` also be implemented server side so API users benefit too, and does that conflict with staying compatible with Tailscale's API? +- Preauth keys need a short prefix to be addressable without the full secret — confirm we're happy adding that. +- Should `headscale tags list` show which nodes are associated with each tag? + +[^1]: A tag is easily identifiable because it always starts with `tag:`.