cli: distinguish no match from ambiguous match when resolving users

resolveSingleUser reported every non-single result as "multiple users
match query", including zero matches. An explicit --identifier 0 was
sent to the API, which treats id=0 as no filter, so it listed every
user and failed as ambiguous. Return a not-found error for zero matches,
list the matching users when several match, and reject a non-positive
identifier before calling the API.
This commit is contained in:
Michael Lopez
2026-09-25 12:55:04 +00:00
committed by Kristoffer Dalby
parent e4b4b89448
commit 3746ad20db
3 changed files with 79 additions and 12 deletions
+1
View File
@@ -53,6 +53,7 @@ tags; any other tag is rejected, for new and re-registering nodes alike. See
- Expiring or deleting a non-existent pre-auth key now returns an error instead of silently succeeding [#3324](https://github.com/juanfont/headscale/pull/3324)
- Improve systemd service file hardening [#3341](https://github.com/juanfont/headscale/pull/3341)
- Fix `headscale users destroy`/`rename` reporting "multiple users match query" when no user matches; an ambiguous match now lists the matching users [#3476](https://github.com/juanfont/headscale/pull/3476)
- Headscale now requires Go 1.27 to build
## 0.29.4 (2026-09-23)
+32 -4
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
"github.com/juanfont/headscale/hscontrol/util"
@@ -19,6 +20,8 @@ import (
var (
errFlagRequired = errors.New("--name or --identifier flag is required")
errMultipleUsersMatch = errors.New("multiple users match query, specify an ID")
errUserNotFound = errors.New("no user matches query")
errInvalidIdentifier = errors.New("--identifier must be a positive user ID")
)
func usernameAndIDFlag(cmd *cobra.Command) {
@@ -31,6 +34,13 @@ func usernameAndIDFromFlag(cmd *cobra.Command) (uint64, string, error) {
username, _ := cmd.Flags().GetString("name")
identifier, _ := cmd.Flags().GetInt64("identifier")
// An explicit zero or negative identifier never matches a user; the
// API treats id=0 as "no filter", which would list every user.
if cmd.Flags().Changed("identifier") && identifier <= 0 {
return 0, "", errInvalidIdentifier
}
if username == "" && identifier < 0 {
return 0, "", errFlagRequired
}
@@ -74,11 +84,29 @@ func resolveSingleUser(
}
users := resp.JSON200.Users
if len(users) != 1 {
return "", nil, errMultipleUsersMatch
switch len(users) {
case 0:
return "", nil, errUserNotFound
case 1:
return users[0].Id, &users[0], nil
default:
return "", nil, fmt.Errorf("%w: %s", errMultipleUsersMatch, describeUsers(users))
}
}
// describeUsers renders the users that matched an ambiguous query so the
// operator can pick one by ID.
func describeUsers(users []clientv1.User) string {
parts := make([]string, len(users))
for i, user := range users {
parts[i] = fmt.Sprintf(
"id=%s name=%s email=%s provider=%s",
user.Id, user.Name, user.Email, user.Provider,
)
}
return users[0].Id, &users[0], nil
return strings.Join(parts, "; ")
}
func init() {
@@ -162,7 +190,7 @@ var destroyUserCmd = &cobra.Command{
}
if !confirmAction(cmd, fmt.Sprintf(
"Do you want to remove the user %q (%s) and any associated preauthkeys?",
"Do you want to remove the user %q (%s) and its pre-auth keys? Nodes owned by the user must be deleted or moved first.",
user.Name, user.Id,
)) {
return printOutput(cmd, map[string]string{colResult: "User not destroyed"}, "User not destroyed")
+46 -8
View File
@@ -3,8 +3,10 @@ package cli
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
clientv1 "github.com/juanfont/headscale/gen/client/v1"
@@ -81,6 +83,9 @@ func TestResolveSingleUser(t *testing.T) {
flagName string
wantId string
wantErr bool
wantErrIs error
// wantErrHas are substrings the error message must contain.
wantErrHas []string
}{
{
// Regression: renaming by name used to return the raw flag
@@ -97,17 +102,40 @@ func TestResolveSingleUser(t *testing.T) {
wantId: "9",
},
{
name: "no match is an error",
users: []clientv1.User{lukas},
flagName: "nobody@example.com",
wantErr: true,
// Regression: zero matches used to be reported as
// "multiple users match query".
name: "no match is a not-found error",
users: []clientv1.User{lukas},
flagName: "nobody@example.com",
wantErr: true,
wantErrIs: errUserNotFound,
},
{
// OIDC users can share a name, see issue #3429.
name: "multiple matches are an error",
users: []clientv1.User{hannes, hannesDup},
flagName: "hannes@rueger.events",
wantErr: true,
name: "multiple matches are an ambiguity error listing the matches",
users: []clientv1.User{hannes, hannesDup},
flagName: "hannes@rueger.events",
wantErr: true,
wantErrIs: errMultipleUsersMatch,
wantErrHas: []string{
"id=9 name=hannes@rueger.events email=hannes@rueger.events",
"id=10 name=hannes@rueger.events email=other@example.com",
},
},
{
// Regression: --identifier 0 was sent to the API as "no
// filter", listing every user and failing as ambiguous.
name: "identifier zero is rejected before calling the API",
users: []clientv1.User{lukas, hannes},
identifier: "0",
wantErr: true,
wantErrIs: errInvalidIdentifier,
},
{
name: "no flags is a usage error",
users: []clientv1.User{lukas},
wantErr: true,
wantErrIs: errFlagRequired,
},
}
@@ -129,6 +157,16 @@ func TestResolveSingleUser(t *testing.T) {
t.Fatalf("resolveSingleUser() error = nil, want error")
}
if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) {
t.Fatalf("resolveSingleUser() error = %v, want %v", err, tt.wantErrIs)
}
for _, want := range tt.wantErrHas {
if !strings.Contains(err.Error(), want) {
t.Errorf("resolveSingleUser() error = %q, want it to contain %q", err, want)
}
}
return
}