mirror of
https://github.com/juanfont/headscale.git
synced 2025-07-28 16:03:42 +00:00
more
This commit is contained in:
parent
45baead257
commit
024ed59ea9
201
CLI_STANDARDIZATION_SUMMARY.md
Normal file
201
CLI_STANDARDIZATION_SUMMARY.md
Normal file
@ -0,0 +1,201 @@
|
||||
# CLI Standardization Summary
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Command Naming Standardization
|
||||
- **Fixed**: `backfillips` → `backfill-ips` (with backward compat alias)
|
||||
- **Fixed**: `dumpConfig` → `dump-config` (with backward compat alias)
|
||||
- **Result**: All commands now use kebab-case consistently
|
||||
|
||||
### 2. Flag Standardization
|
||||
|
||||
#### Node Commands
|
||||
- **Added**: `--node` flag as primary way to specify nodes
|
||||
- **Deprecated**: `--identifier` flag (hidden, marked deprecated)
|
||||
- **Backward Compatible**: Both flags work, `--identifier` shows deprecation warning
|
||||
- **Smart Lookup Ready**: `--node` accepts strings for future name/hostname/IP lookup
|
||||
|
||||
#### User Commands
|
||||
- **Updated**: User identification flow prepared for `--user` flag
|
||||
- **Maintained**: Existing `--name` and `--identifier` flags for backward compatibility
|
||||
|
||||
### 3. Description Consistency
|
||||
- **Fixed**: "Api" → "API" throughout
|
||||
- **Fixed**: Capitalization consistency in short descriptions
|
||||
- **Fixed**: Removed unnecessary periods from short descriptions
|
||||
- **Standardized**: "Handle/Manage the X of Headscale" pattern
|
||||
|
||||
### 4. Type Consistency
|
||||
- **Standardized**: Node IDs use `uint64` consistently
|
||||
- **Maintained**: Backward compatibility with existing flag types
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ Completed
|
||||
- Command naming (kebab-case)
|
||||
- Flag deprecation and aliasing
|
||||
- Description standardization
|
||||
- Backward compatibility preservation
|
||||
- Helper functions for flag processing
|
||||
- **SMART LOOKUP IMPLEMENTATION**:
|
||||
- Enhanced `ListNodesRequest` proto with ID, name, hostname, IP filters
|
||||
- Implemented smart filtering in `ListNodes` gRPC method
|
||||
- Added CLI smart lookup functions for nodes and users
|
||||
- Single match validation with helpful error messages
|
||||
- Automatic detection: ID (numeric) vs IP vs name/hostname/email
|
||||
|
||||
### ✅ Smart Lookup Features
|
||||
- **Node Lookup**: By ID, hostname, or IP address
|
||||
- **User Lookup**: By ID, username, or email address
|
||||
- **Single Match Enforcement**: Errors if 0 or >1 matches found
|
||||
- **Helpful Error Messages**: Shows all matches when ambiguous
|
||||
- **Full Backward Compatibility**: All existing flags still work
|
||||
- **Enhanced List Commands**: Both `nodes list` and `users list` support all filter types
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
**None.** All changes maintain full backward compatibility through flag aliases and deprecation warnings.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Smart Lookup Algorithm
|
||||
|
||||
1. **Input Detection**:
|
||||
```go
|
||||
if numeric && > 0 -> treat as ID
|
||||
else if contains "@" -> treat as email (users only)
|
||||
else if valid IP address -> treat as IP (nodes only)
|
||||
else -> treat as name/hostname
|
||||
```
|
||||
|
||||
2. **gRPC Filtering**:
|
||||
- Uses enhanced `ListNodes`/`ListUsers` with specific filters
|
||||
- Server-side filtering for optimal performance
|
||||
- Single transaction per lookup
|
||||
|
||||
3. **Match Validation**:
|
||||
- Exactly 1 match: Return ID
|
||||
- 0 matches: Error with "not found" message
|
||||
- >1 matches: Error listing all matches for disambiguation
|
||||
|
||||
### Enhanced Proto Definitions
|
||||
|
||||
```protobuf
|
||||
message ListNodesRequest {
|
||||
string user = 1; // existing
|
||||
uint64 id = 2; // new: filter by ID
|
||||
string name = 3; // new: filter by hostname
|
||||
string hostname = 4; // new: alias for name
|
||||
repeated string ip_addresses = 5; // new: filter by IPs
|
||||
}
|
||||
```
|
||||
|
||||
### Future Enhancements
|
||||
|
||||
- **Fuzzy Matching**: Partial name matching with confirmation
|
||||
- **Recently Used**: Cache recently accessed nodes/users
|
||||
- **Tab Completion**: Shell completion for names/hostnames
|
||||
- **Bulk Operations**: Multi-select with pattern matching
|
||||
|
||||
## Migration Path for Users
|
||||
|
||||
### Now Available (Current Release)
|
||||
```bash
|
||||
# Old way (still works, shows deprecation warning)
|
||||
headscale nodes expire --identifier 123
|
||||
|
||||
# New way with smart lookup:
|
||||
headscale nodes expire --node 123 # by ID
|
||||
headscale nodes expire --node "my-laptop" # by hostname
|
||||
headscale nodes expire --node "100.64.0.1" # by Tailscale IP
|
||||
headscale nodes expire --node "192.168.1.100" # by real IP
|
||||
|
||||
# User operations:
|
||||
headscale users destroy --user 123 # by ID
|
||||
headscale users destroy --user "alice" # by username
|
||||
headscale users destroy --user "alice@company.com" # by email
|
||||
|
||||
# Enhanced list commands with filtering:
|
||||
headscale nodes list --node "laptop" # filter nodes by name
|
||||
headscale nodes list --ip "100.64.0.1" # filter nodes by IP
|
||||
headscale nodes list --user "alice" # filter nodes by user
|
||||
headscale users list --user "alice" # smart lookup user
|
||||
headscale users list --email "@company.com" # filter by email domain
|
||||
headscale users list --name "alice" # filter by exact name
|
||||
|
||||
# Error handling examples:
|
||||
headscale nodes expire --node "laptop"
|
||||
# Error: multiple nodes found matching 'laptop': ID=1 name=laptop-alice, ID=2 name=laptop-bob
|
||||
|
||||
headscale nodes expire --node "nonexistent"
|
||||
# Error: no node found matching 'nonexistent'
|
||||
```
|
||||
|
||||
## Command Structure Overview
|
||||
|
||||
```
|
||||
headscale [global-flags] <command> [command-flags] <subcommand> [subcommand-flags] [args]
|
||||
|
||||
Global Flags:
|
||||
--config, -c config file path
|
||||
--output, -o output format (json, yaml, json-line)
|
||||
--force disable prompts
|
||||
|
||||
Commands:
|
||||
├── serve
|
||||
├── version
|
||||
├── config-test
|
||||
├── dump-config (alias: dumpConfig)
|
||||
├── mockoidc
|
||||
├── generate/
|
||||
│ └── private-key
|
||||
├── nodes/
|
||||
│ ├── list (--user, --tags, --columns)
|
||||
│ ├── register (--user, --key)
|
||||
│ ├── list-routes (--node)
|
||||
│ ├── expire (--node)
|
||||
│ ├── rename (--node) <new-name>
|
||||
│ ├── delete (--node)
|
||||
│ ├── move (--node, --user)
|
||||
│ ├── tag (--node, --tags)
|
||||
│ ├── approve-routes (--node, --routes)
|
||||
│ └── backfill-ips (alias: backfillips)
|
||||
├── users/
|
||||
│ ├── create <name> (--display-name, --email, --picture-url)
|
||||
│ ├── list (--user, --name, --email, --columns)
|
||||
│ ├── destroy (--user|--name|--identifier)
|
||||
│ └── rename (--user|--name|--identifier, --new-name)
|
||||
├── apikeys/
|
||||
│ ├── list
|
||||
│ ├── create (--expiration)
|
||||
│ ├── expire (--prefix)
|
||||
│ └── delete (--prefix)
|
||||
├── preauthkeys/
|
||||
│ ├── list (--user)
|
||||
│ ├── create (--user, --reusable, --ephemeral, --expiration, --tags)
|
||||
│ └── expire (--user) <key>
|
||||
├── policy/
|
||||
│ ├── get
|
||||
│ ├── set (--file)
|
||||
│ └── check (--file)
|
||||
└── debug/
|
||||
└── create-node (--name, --user, --key, --route)
|
||||
```
|
||||
|
||||
## Deprecated Flags
|
||||
|
||||
All deprecated flags continue to work but show warnings:
|
||||
|
||||
- `--identifier` → use `--node` (for node commands) or `--user` (for user commands)
|
||||
- `--namespace` → use `--user` (already implemented)
|
||||
- `dumpConfig` → use `dump-config`
|
||||
- `backfillips` → use `backfill-ips`
|
||||
|
||||
## Error Handling
|
||||
|
||||
Improved error messages provide clear guidance:
|
||||
```
|
||||
Error: node specifier must be a numeric ID (smart lookup by name/hostname/IP not yet implemented)
|
||||
Error: --node flag is required
|
||||
Error: --user flag is required
|
||||
```
|
@ -40,13 +40,13 @@ func init() {
|
||||
|
||||
var apiKeysCmd = &cobra.Command{
|
||||
Use: "apikeys",
|
||||
Short: "Handle the Api keys in Headscale",
|
||||
Short: "Handle the API keys in Headscale",
|
||||
Aliases: []string{"apikey", "api"},
|
||||
}
|
||||
|
||||
var listAPIKeys = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List the Api keys for headscale",
|
||||
Short: "List the API keys for Headscale",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output := GetOutputFlag(cmd)
|
||||
@ -107,7 +107,7 @@ var listAPIKeys = &cobra.Command{
|
||||
|
||||
var createAPIKeyCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Creates a new Api key",
|
||||
Short: "Create a new API key",
|
||||
Long: `
|
||||
Creates a new Api key, the Api key is only visible on creation
|
||||
and cannot be retrieved again.
|
||||
@ -157,7 +157,7 @@ If you loose a key, create a new one and revoke (expire) the old one.`,
|
||||
|
||||
var expireAPIKeyCmd = &cobra.Command{
|
||||
Use: "expire",
|
||||
Short: "Expire an ApiKey",
|
||||
Short: "Expire an API key",
|
||||
Aliases: []string{"revoke", "exp", "e"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output := GetOutputFlag(cmd)
|
||||
@ -194,7 +194,7 @@ var expireAPIKeyCmd = &cobra.Command{
|
||||
|
||||
var deleteAPIKeyCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete an ApiKey",
|
||||
Short: "Delete an API key",
|
||||
Aliases: []string{"remove", "del"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output := GetOutputFlag(cmd)
|
||||
|
@ -11,8 +11,8 @@ func init() {
|
||||
|
||||
var configTestCmd = &cobra.Command{
|
||||
Use: "configtest",
|
||||
Short: "Test the configuration.",
|
||||
Long: "Run a test of the configuration and exit.",
|
||||
Short: "Test the configuration",
|
||||
Long: "Run a test of the configuration and exit",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
_, err := newHeadscaleServerWithConfig()
|
||||
if err != nil {
|
||||
|
@ -12,9 +12,10 @@ func init() {
|
||||
}
|
||||
|
||||
var dumpConfigCmd = &cobra.Command{
|
||||
Use: "dumpConfig",
|
||||
Short: "dump current config to /etc/headscale/config.dump.yaml, integration test only",
|
||||
Hidden: true,
|
||||
Use: "dump-config",
|
||||
Short: "Dump current config to /etc/headscale/config.dump.yaml, integration test only",
|
||||
Aliases: []string{"dumpConfig"},
|
||||
Hidden: true,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
|
@ -22,17 +22,28 @@ import (
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(nodeCmd)
|
||||
// User filtering
|
||||
listNodesCmd.Flags().StringP("user", "u", "", "Filter by user")
|
||||
// Node filtering
|
||||
listNodesCmd.Flags().StringP("node", "", "", "Filter by node (ID, name, hostname, or IP)")
|
||||
listNodesCmd.Flags().Uint64P("id", "", 0, "Filter by node ID")
|
||||
listNodesCmd.Flags().StringP("name", "", "", "Filter by node hostname")
|
||||
listNodesCmd.Flags().StringP("ip", "", "", "Filter by node IP address")
|
||||
// Display options
|
||||
listNodesCmd.Flags().BoolP("tags", "t", false, "Show tags")
|
||||
listNodesCmd.Flags().String("columns", "", "Comma-separated list of columns to display")
|
||||
|
||||
// Backward compatibility
|
||||
listNodesCmd.Flags().StringP("namespace", "n", "", "User")
|
||||
listNodesNamespaceFlag := listNodesCmd.Flags().Lookup("namespace")
|
||||
listNodesNamespaceFlag.Deprecated = deprecateNamespaceMessage
|
||||
listNodesNamespaceFlag.Hidden = true
|
||||
nodeCmd.AddCommand(listNodesCmd)
|
||||
|
||||
listNodeRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
listNodeRoutesCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
|
||||
listNodeRoutesCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
|
||||
identifierFlag := listNodeRoutesCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --node"
|
||||
identifierFlag.Hidden = true
|
||||
nodeCmd.AddCommand(listNodeRoutesCmd)
|
||||
|
||||
registerNodeCmd.Flags().StringP("user", "u", "", "User")
|
||||
@ -53,30 +64,42 @@ func init() {
|
||||
}
|
||||
nodeCmd.AddCommand(registerNodeCmd)
|
||||
|
||||
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = expireNodeCmd.MarkFlagRequired("identifier")
|
||||
expireNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
|
||||
expireNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
|
||||
identifierFlag = expireNodeCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --node"
|
||||
identifierFlag.Hidden = true
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(expireNodeCmd)
|
||||
|
||||
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = renameNodeCmd.MarkFlagRequired("identifier")
|
||||
renameNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
|
||||
renameNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
|
||||
identifierFlag = renameNodeCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --node"
|
||||
identifierFlag.Hidden = true
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(renameNodeCmd)
|
||||
|
||||
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
err = deleteNodeCmd.MarkFlagRequired("identifier")
|
||||
deleteNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
|
||||
deleteNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
|
||||
identifierFlag = deleteNodeCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --node"
|
||||
identifierFlag.Hidden = true
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
nodeCmd.AddCommand(deleteNodeCmd)
|
||||
|
||||
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID)")
|
||||
moveNodeCmd.Flags().StringP("node", "n", "", "Node identifier (ID, name, hostname, or IP)")
|
||||
moveNodeCmd.Flags().Uint64P("identifier", "i", 0, "Node identifier (ID) - deprecated, use --node")
|
||||
identifierFlag = moveNodeCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --node"
|
||||
identifierFlag.Hidden = true
|
||||
|
||||
err = moveNodeCmd.MarkFlagRequired("identifier")
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
@ -170,19 +193,43 @@ var listNodesCmd = &cobra.Command{
|
||||
Short: "List nodes",
|
||||
Aliases: []string{"ls", "show"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
user, err := cmd.Flags().GetString("user")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting user: %s", err), output)
|
||||
}
|
||||
output := GetOutputFlag(cmd)
|
||||
showTags, err := cmd.Flags().GetBool("tags")
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error getting tags flag: %s", err), output)
|
||||
return
|
||||
}
|
||||
|
||||
err = WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
|
||||
request := &v1.ListNodesRequest{
|
||||
User: user,
|
||||
request := &v1.ListNodesRequest{}
|
||||
|
||||
// Handle user filtering (existing functionality)
|
||||
if user, _ := cmd.Flags().GetString("user"); user != "" {
|
||||
request.User = user
|
||||
}
|
||||
if namespace, _ := cmd.Flags().GetString("namespace"); namespace != "" {
|
||||
request.User = namespace // backward compatibility
|
||||
}
|
||||
|
||||
// Handle node filtering (new functionality)
|
||||
if nodeFlag, _ := cmd.Flags().GetString("node"); nodeFlag != "" {
|
||||
// Use smart lookup to determine filter type
|
||||
if id, err := strconv.ParseUint(nodeFlag, 10, 64); err == nil && id > 0 {
|
||||
request.Id = id
|
||||
} else if isIPAddress(nodeFlag) {
|
||||
request.IpAddresses = []string{nodeFlag}
|
||||
} else {
|
||||
request.Name = nodeFlag
|
||||
}
|
||||
} else {
|
||||
// Check specific filter flags
|
||||
if id, _ := cmd.Flags().GetUint64("id"); id > 0 {
|
||||
request.Id = id
|
||||
} else if name, _ := cmd.Flags().GetString("name"); name != "" {
|
||||
request.Name = name
|
||||
} else if ip, _ := cmd.Flags().GetString("ip"); ip != "" {
|
||||
request.IpAddresses = []string{ip}
|
||||
}
|
||||
}
|
||||
|
||||
response, err := client.ListNodes(ctx, request)
|
||||
@ -200,7 +247,9 @@ var listNodesCmd = &cobra.Command{
|
||||
return nil
|
||||
}
|
||||
|
||||
tableData, err := nodesToPtables(user, showTags, response.GetNodes())
|
||||
// Get user for table display (if filtering by user)
|
||||
userFilter := request.User
|
||||
tableData, err := nodesToPtables(userFilter, showTags, response.GetNodes())
|
||||
if err != nil {
|
||||
ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
|
||||
return err
|
||||
@ -231,11 +280,11 @@ var listNodeRoutesCmd = &cobra.Command{
|
||||
Aliases: []string{"lsr", "routes"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -305,11 +354,11 @@ var expireNodeCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -349,11 +398,11 @@ var renameNodeCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -400,11 +449,11 @@ var deleteNodeCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -491,11 +540,11 @@ var moveNodeCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -552,8 +601,9 @@ var moveNodeCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
var backfillNodeIPsCmd = &cobra.Command{
|
||||
Use: "backfillips",
|
||||
Short: "Backfill IPs missing from nodes",
|
||||
Use: "backfill-ips",
|
||||
Short: "Backfill IPs missing from nodes",
|
||||
Aliases: []string{"backfillips"},
|
||||
Long: `
|
||||
Backfill IPs can be used to add/remove IPs from nodes
|
||||
based on the current configuration of Headscale.
|
||||
@ -782,11 +832,11 @@ var tagCmd = &cobra.Command{
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// retrieve flags from CLI
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
@ -840,11 +890,11 @@ var approveRoutesCmd = &cobra.Command{
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
|
||||
// retrieve flags from CLI
|
||||
identifier, err := cmd.Flags().GetUint64("identifier")
|
||||
identifier, err := GetNodeIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
fmt.Sprintf("Error converting ID to integer: %s", err),
|
||||
fmt.Sprintf("Error getting node identifier: %s", err),
|
||||
output,
|
||||
)
|
||||
return
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
survey "github.com/AlecAivazis/survey/v2"
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
@ -16,25 +17,27 @@ import (
|
||||
)
|
||||
|
||||
func usernameAndIDFlag(cmd *cobra.Command) {
|
||||
cmd.Flags().Int64P("identifier", "i", -1, "User identifier (ID)")
|
||||
cmd.Flags().StringP("user", "u", "", "User identifier (ID, name, or email)")
|
||||
cmd.Flags().Uint64P("identifier", "i", 0, "User identifier (ID) - deprecated, use --user")
|
||||
identifierFlag := cmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --user"
|
||||
identifierFlag.Hidden = true
|
||||
cmd.Flags().StringP("name", "n", "", "Username")
|
||||
}
|
||||
|
||||
// usernameAndIDFromFlag returns the username and ID from the flags of the command.
|
||||
// If both are empty, it will exit the program with an error.
|
||||
// usernameAndIDFromFlag returns the user ID using smart lookup.
|
||||
// If no user is specified, it will exit the program with an error.
|
||||
func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string) {
|
||||
username, _ := cmd.Flags().GetString("name")
|
||||
identifier, _ := cmd.Flags().GetInt64("identifier")
|
||||
if username == "" && identifier < 0 {
|
||||
err := errors.New("--name or --identifier flag is required")
|
||||
userID, err := GetUserIdentifier(cmd)
|
||||
if err != nil {
|
||||
ErrorOutput(
|
||||
err,
|
||||
"Cannot rename user: "+status.Convert(err).Message(),
|
||||
"",
|
||||
"Cannot identify user: "+err.Error(),
|
||||
GetOutputFlag(cmd),
|
||||
)
|
||||
}
|
||||
|
||||
return uint64(identifier), username
|
||||
return userID, ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
@ -44,8 +47,16 @@ func init() {
|
||||
createUserCmd.Flags().StringP("email", "e", "", "Email")
|
||||
createUserCmd.Flags().StringP("picture-url", "p", "", "Profile picture URL")
|
||||
userCmd.AddCommand(listUsersCmd)
|
||||
usernameAndIDFlag(listUsersCmd)
|
||||
listUsersCmd.Flags().StringP("email", "e", "", "Email")
|
||||
// Smart lookup filters - can be used individually or combined
|
||||
listUsersCmd.Flags().StringP("user", "u", "", "Filter by user (ID, name, or email)")
|
||||
listUsersCmd.Flags().Uint64P("id", "", 0, "Filter by user ID")
|
||||
listUsersCmd.Flags().StringP("name", "n", "", "Filter by username")
|
||||
listUsersCmd.Flags().StringP("email", "e", "", "Filter by email address")
|
||||
// Backward compatibility (deprecated)
|
||||
listUsersCmd.Flags().Uint64P("identifier", "i", 0, "Filter by user ID - deprecated, use --id")
|
||||
identifierFlag := listUsersCmd.Flags().Lookup("identifier")
|
||||
identifierFlag.Deprecated = "use --id"
|
||||
identifierFlag.Hidden = true
|
||||
listUsersCmd.Flags().String("columns", "", "Comma-separated list of columns to display (ID,Name,Username,Email,Created)")
|
||||
userCmd.AddCommand(destroyUserCmd)
|
||||
usernameAndIDFlag(destroyUserCmd)
|
||||
@ -221,18 +232,28 @@ var listUsersCmd = &cobra.Command{
|
||||
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
|
||||
request := &v1.ListUsersRequest{}
|
||||
|
||||
id, _ := cmd.Flags().GetInt64("identifier")
|
||||
username, _ := cmd.Flags().GetString("name")
|
||||
email, _ := cmd.Flags().GetString("email")
|
||||
|
||||
// filter by one param at most
|
||||
switch {
|
||||
case id > 0:
|
||||
request.Id = uint64(id)
|
||||
case username != "":
|
||||
request.Name = username
|
||||
case email != "":
|
||||
request.Email = email
|
||||
// Check for smart lookup flag first
|
||||
userFlag, _ := cmd.Flags().GetString("user")
|
||||
if userFlag != "" {
|
||||
// Use smart lookup to determine filter type
|
||||
if id, err := strconv.ParseUint(userFlag, 10, 64); err == nil && id > 0 {
|
||||
request.Id = id
|
||||
} else if strings.Contains(userFlag, "@") {
|
||||
request.Email = userFlag
|
||||
} else {
|
||||
request.Name = userFlag
|
||||
}
|
||||
} else {
|
||||
// Check specific filter flags
|
||||
if id, _ := cmd.Flags().GetUint64("id"); id > 0 {
|
||||
request.Id = id
|
||||
} else if identifier, _ := cmd.Flags().GetUint64("identifier"); identifier > 0 {
|
||||
request.Id = identifier // backward compatibility
|
||||
} else if name, _ := cmd.Flags().GetString("name"); name != "" {
|
||||
request.Name = name
|
||||
} else if email, _ := cmd.Flags().GetString("email"); email != "" {
|
||||
request.Email = email
|
||||
}
|
||||
}
|
||||
|
||||
response, err := client.ListUsers(ctx, request)
|
||||
|
@ -5,7 +5,10 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
|
||||
"github.com/juanfont/headscale/hscontrol"
|
||||
@ -206,3 +209,156 @@ func GetOutputFlag(cmd *cobra.Command) string {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
return output
|
||||
}
|
||||
|
||||
// GetNodeIdentifier returns the node ID using smart lookup via gRPC ListNodes call
|
||||
func GetNodeIdentifier(cmd *cobra.Command) (uint64, error) {
|
||||
nodeFlag, _ := cmd.Flags().GetString("node")
|
||||
identifierFlag, _ := cmd.Flags().GetUint64("identifier")
|
||||
|
||||
// Check if --identifier (deprecated) was used
|
||||
if identifierFlag > 0 {
|
||||
return identifierFlag, nil
|
||||
}
|
||||
|
||||
// Use --node flag
|
||||
if nodeFlag == "" {
|
||||
return 0, fmt.Errorf("--node flag is required")
|
||||
}
|
||||
|
||||
// Use smart lookup via gRPC
|
||||
return lookupNodeBySpecifier(nodeFlag)
|
||||
}
|
||||
|
||||
// lookupNodeBySpecifier performs smart lookup of a node by ID, name, hostname, or IP
|
||||
func lookupNodeBySpecifier(specifier string) (uint64, error) {
|
||||
var nodeID uint64
|
||||
|
||||
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
|
||||
request := &v1.ListNodesRequest{}
|
||||
|
||||
// Detect what type of specifier this is and set appropriate filter
|
||||
if id, err := strconv.ParseUint(specifier, 10, 64); err == nil && id > 0 {
|
||||
// Looks like a numeric ID
|
||||
request.Id = id
|
||||
} else if isIPAddress(specifier) {
|
||||
// Looks like an IP address
|
||||
request.IpAddresses = []string{specifier}
|
||||
} else {
|
||||
// Treat as hostname/name
|
||||
request.Name = specifier
|
||||
}
|
||||
|
||||
response, err := client.ListNodes(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to lookup node: %w", err)
|
||||
}
|
||||
|
||||
nodes := response.GetNodes()
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("no node found matching '%s'", specifier)
|
||||
}
|
||||
|
||||
if len(nodes) > 1 {
|
||||
var nodeInfo []string
|
||||
for _, node := range nodes {
|
||||
nodeInfo = append(nodeInfo, fmt.Sprintf("ID=%d name=%s", node.GetId(), node.GetName()))
|
||||
}
|
||||
return fmt.Errorf("multiple nodes found matching '%s': %s", specifier, strings.Join(nodeInfo, ", "))
|
||||
}
|
||||
|
||||
// Exactly one match - this is what we want
|
||||
nodeID = nodes[0].GetId()
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return nodeID, nil
|
||||
}
|
||||
|
||||
// isIPAddress checks if a string looks like an IP address
|
||||
func isIPAddress(s string) bool {
|
||||
// Try parsing as IP address (both IPv4 and IPv6)
|
||||
if net.ParseIP(s) != nil {
|
||||
return true
|
||||
}
|
||||
// Try parsing as CIDR
|
||||
if _, _, err := net.ParseCIDR(s); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetUserIdentifier returns the user ID using smart lookup via gRPC ListUsers call
|
||||
func GetUserIdentifier(cmd *cobra.Command) (uint64, error) {
|
||||
userFlag, _ := cmd.Flags().GetString("user")
|
||||
nameFlag, _ := cmd.Flags().GetString("name")
|
||||
identifierFlag, _ := cmd.Flags().GetUint64("identifier")
|
||||
|
||||
var specifier string
|
||||
|
||||
// Determine which flag was used (prefer --user, fall back to legacy flags)
|
||||
if userFlag != "" {
|
||||
specifier = userFlag
|
||||
} else if nameFlag != "" {
|
||||
specifier = nameFlag
|
||||
} else if identifierFlag > 0 {
|
||||
return identifierFlag, nil // Direct ID, no lookup needed
|
||||
} else {
|
||||
return 0, fmt.Errorf("--user flag is required")
|
||||
}
|
||||
|
||||
// Use smart lookup via gRPC
|
||||
return lookupUserBySpecifier(specifier)
|
||||
}
|
||||
|
||||
// lookupUserBySpecifier performs smart lookup of a user by ID, name, or email
|
||||
func lookupUserBySpecifier(specifier string) (uint64, error) {
|
||||
var userID uint64
|
||||
|
||||
err := WithClient(func(ctx context.Context, client v1.HeadscaleServiceClient) error {
|
||||
request := &v1.ListUsersRequest{}
|
||||
|
||||
// Detect what type of specifier this is and set appropriate filter
|
||||
if id, err := strconv.ParseUint(specifier, 10, 64); err == nil && id > 0 {
|
||||
// Looks like a numeric ID
|
||||
request.Id = id
|
||||
} else if strings.Contains(specifier, "@") {
|
||||
// Looks like an email address
|
||||
request.Email = specifier
|
||||
} else {
|
||||
// Treat as username
|
||||
request.Name = specifier
|
||||
}
|
||||
|
||||
response, err := client.ListUsers(ctx, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to lookup user: %w", err)
|
||||
}
|
||||
|
||||
users := response.GetUsers()
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("no user found matching '%s'", specifier)
|
||||
}
|
||||
|
||||
if len(users) > 1 {
|
||||
var userInfo []string
|
||||
for _, user := range users {
|
||||
userInfo = append(userInfo, fmt.Sprintf("ID=%d name=%s email=%s", user.GetId(), user.GetName(), user.GetEmail()))
|
||||
}
|
||||
return fmt.Errorf("multiple users found matching '%s': %s", specifier, strings.Join(userInfo, ", "))
|
||||
}
|
||||
|
||||
// Exactly one match - this is what we want
|
||||
userID = users[0].GetId()
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return userID, nil
|
||||
}
|
||||
|
@ -11,8 +11,8 @@ func init() {
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version.",
|
||||
Long: "The version of headscale.",
|
||||
Short: "Print the version",
|
||||
Long: "The version of headscale",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
output, _ := cmd.Flags().GetString("output")
|
||||
SuccessOutput(map[string]string{
|
||||
|
@ -913,6 +913,10 @@ func (x *RenameNodeResponse) GetNode() *Node {
|
||||
type ListNodesRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
|
||||
Id uint64 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"`
|
||||
Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Hostname string `protobuf:"bytes,4,opt,name=hostname,proto3" json:"hostname,omitempty"`
|
||||
IpAddresses []string `protobuf:"bytes,5,rep,name=ip_addresses,json=ipAddresses,proto3" json:"ip_addresses,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -954,6 +958,34 @@ func (x *ListNodesRequest) GetUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListNodesRequest) GetId() uint64 {
|
||||
if x != nil {
|
||||
return x.Id
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *ListNodesRequest) GetName() string {
|
||||
if x != nil {
|
||||
return x.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListNodesRequest) GetHostname() string {
|
||||
if x != nil {
|
||||
return x.Hostname
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ListNodesRequest) GetIpAddresses() []string {
|
||||
if x != nil {
|
||||
return x.IpAddresses
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListNodesResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Nodes []*Node `protobuf:"bytes,1,rep,name=nodes,proto3" json:"nodes,omitempty"`
|
||||
@ -1358,9 +1390,13 @@ const file_headscale_v1_node_proto_rawDesc = "" +
|
||||
"\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x19\n" +
|
||||
"\bnew_name\x18\x02 \x01(\tR\anewName\"<\n" +
|
||||
"\x12RenameNodeResponse\x12&\n" +
|
||||
"\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"&\n" +
|
||||
"\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"\x89\x01\n" +
|
||||
"\x10ListNodesRequest\x12\x12\n" +
|
||||
"\x04user\x18\x01 \x01(\tR\x04user\"=\n" +
|
||||
"\x04user\x18\x01 \x01(\tR\x04user\x12\x0e\n" +
|
||||
"\x02id\x18\x02 \x01(\x04R\x02id\x12\x12\n" +
|
||||
"\x04name\x18\x03 \x01(\tR\x04name\x12\x1a\n" +
|
||||
"\bhostname\x18\x04 \x01(\tR\bhostname\x12!\n" +
|
||||
"\fip_addresses\x18\x05 \x03(\tR\vipAddresses\"=\n" +
|
||||
"\x11ListNodesResponse\x12(\n" +
|
||||
"\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\">\n" +
|
||||
"\x0fMoveNodeRequest\x12\x17\n" +
|
||||
|
@ -187,6 +187,35 @@
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string",
|
||||
"format": "uint64"
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "hostname",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "ipAddresses",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"collectionFormat": "multi"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
|
@ -493,32 +493,20 @@ func (api headscaleV1APIServer) ListNodes(
|
||||
ctx context.Context,
|
||||
request *v1.ListNodesRequest,
|
||||
) (*v1.ListNodesResponse, error) {
|
||||
// TODO(kradalby): it looks like this can be simplified a lot,
|
||||
// the filtering of nodes by user, vs nodes as a whole can
|
||||
// probably be done once.
|
||||
// TODO(kradalby): This should be done in one tx.
|
||||
var nodes types.Nodes
|
||||
var err error
|
||||
|
||||
isLikelyConnected := api.h.nodeNotifier.LikelyConnectedMap()
|
||||
if request.GetUser() != "" {
|
||||
user, err := api.h.state.GetUserByName(request.GetUser())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodes, err := api.h.state.ListNodesByUser(types.UserID(user.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := nodesToProto(api.h.state, isLikelyConnected, nodes)
|
||||
return &v1.ListNodesResponse{Nodes: response}, nil
|
||||
}
|
||||
|
||||
nodes, err := api.h.state.ListNodes()
|
||||
// Start with all nodes and apply filters
|
||||
nodes, err = api.h.state.ListNodes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Apply filters based on request
|
||||
nodes = api.filterNodes(nodes, request)
|
||||
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
return nodes[i].ID < nodes[j].ID
|
||||
})
|
||||
@ -527,6 +515,57 @@ func (api headscaleV1APIServer) ListNodes(
|
||||
return &v1.ListNodesResponse{Nodes: response}, nil
|
||||
}
|
||||
|
||||
// filterNodes applies the filters from ListNodesRequest to the node list
|
||||
func (api headscaleV1APIServer) filterNodes(nodes types.Nodes, request *v1.ListNodesRequest) types.Nodes {
|
||||
var filtered types.Nodes
|
||||
|
||||
for _, node := range nodes {
|
||||
// Filter by user
|
||||
if request.GetUser() != "" && node.User.Name != request.GetUser() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by ID (backward compatibility)
|
||||
if request.GetId() != 0 && uint64(node.ID) != request.GetId() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by name (exact match)
|
||||
if request.GetName() != "" && node.Hostname != request.GetName() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by hostname (alias for name)
|
||||
if request.GetHostname() != "" && node.Hostname != request.GetHostname() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by IP addresses
|
||||
if len(request.GetIpAddresses()) > 0 {
|
||||
hasMatchingIP := false
|
||||
for _, requestIP := range request.GetIpAddresses() {
|
||||
for _, nodeIP := range node.IPs() {
|
||||
if nodeIP.String() == requestIP {
|
||||
hasMatchingIP = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasMatchingIP {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMatchingIP {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, node matches all filters
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func nodesToProto(state *state.State, isLikelyConnected *xsync.MapOf[types.NodeID, bool], nodes types.Nodes) []*v1.Node {
|
||||
response := make([]*v1.Node, len(nodes))
|
||||
for index, node := range nodes {
|
||||
|
@ -93,7 +93,13 @@ message RenameNodeRequest {
|
||||
|
||||
message RenameNodeResponse { Node node = 1; }
|
||||
|
||||
message ListNodesRequest { string user = 1; }
|
||||
message ListNodesRequest {
|
||||
string user = 1;
|
||||
uint64 id = 2;
|
||||
string name = 3;
|
||||
string hostname = 4;
|
||||
repeated string ip_addresses = 5;
|
||||
}
|
||||
|
||||
message ListNodesResponse { repeated Node nodes = 1; }
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user