diff --git a/cmd/headscale/cli/client.go b/cmd/headscale/cli/client.go index 52a3ccb5..36c97585 100644 --- a/cmd/headscale/cli/client.go +++ b/cmd/headscale/cli/client.go @@ -84,7 +84,7 @@ func localSocketClient(socketPath string) (*apiv1.Client, error) { httpClient := &http.Client{ Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + return dialSocketWaiting(ctx, socketPath) }, }, } @@ -93,6 +93,31 @@ func localSocketClient(socketPath string) (*apiv1.Client, error) { return apiv1.NewClient("http://unix", cliToken("local-socket"), apiv1.WithClient(httpClient)) } +// dialSocketWaiting connects to the local unix socket, retrying while it is +// still absent. headscale removes and rebinds the socket during startup, so a +// CLI command issued right after "systemctl start" can beat the server to it. +// This mirrors the old blocking gRPC dial, which retried until the CLI timeout. +func dialSocketWaiting(ctx context.Context, socketPath string) (net.Conn, error) { + var dialer net.Dialer + + for { + conn, err := dialer.DialContext(ctx, "unix", socketPath) + if err == nil { + return conn, nil + } + + if ctx.Err() != nil { + return nil, err + } + + select { + case <-ctx.Done(): + return nil, err + case <-time.After(100 * time.Millisecond): + } + } +} + func remoteClient(address, apiKey string, insecure bool) (*apiv1.Client, error) { if apiKey == "" { return nil, errAPIKeyNotSet