headscale/docs/proposals/001-cli.md
2026-07-09 15:52:14 +00:00

11 KiB
Raw Blame History

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 <target node identifier> 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.
  2. Is it a name (or the other supported fields below) and does it resolve?
    1. Resolves to one target → return it.
    2. Resolves to multiple → error with the list of candidates.
  3. 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 <id or prefix> 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 <node id> <expiry time>
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 <node id> <route list>
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 <node id> # list tags of node
headscale tags set <node id> <tag list>
headscale tags set 12 tag:example,tag:test

Users, auth, policy and oauth2:

headscale users list
headscale users create <name> # optional fields are flags
headscale users set <name> # optional fields are flags
headscale users rename <old-name> <new-name>
headscale users delete <name>

headscale auth approve <id or prefix>
headscale auth register <id or prefix> <user>
headscale auth reject <id or prefix>

headscale auth key list # list all auth keys
headscale auth key create <user or tag> # [^1], optionals are flags
headscale auth key revoke <id or prefix>
headscale auth key delete <id or prefix>

headscale policy check # reads from database or file path from config
headscale policy check <file path>
headscale policy get
headscale policy set <file path>

headscale api oauth list # list all oauth clients
headscale api oauth create <scope list> <tag list>
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 <id or prefix>
headscale api oauth delete <id or prefix>

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 — 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 instead, via 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?