diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index aa7e3762a..3b32d18b4 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -77,6 +77,20 @@ type errorJSON struct { Error string } +// AccessDeniedError is an error due to permissions. +type AccessDeniedError struct { + err error +} + +func (e *AccessDeniedError) Error() string { return fmt.Sprintf("Access denied: %v", e.err) } +func (e *AccessDeniedError) Unwrap() error { return e.err } + +// IsAccessDeniedError reports whether err is or wraps an AccessDeniedError. +func IsAccessDeniedError(err error) bool { + var ae *AccessDeniedError + return errors.As(err, &ae) +} + // bestError returns either err, or if body contains a valid JSON // object of type errorJSON, its non-empty error body. func bestError(err error, body []byte) error { @@ -87,6 +101,14 @@ func bestError(err error, body []byte) error { return err } +func errorMessageFromBody(body []byte) string { + var j errorJSON + if err := json.Unmarshal(body, &j); err == nil && j.Error != "" { + return j.Error + } + return strings.TrimSpace(string(body)) +} + var onVersionMismatch func(clientVer, serverVer string) // SetVersionMismatchHandler sets f as the version mismatch handler @@ -123,6 +145,9 @@ func send(ctx context.Context, method, path string, wantStatus int, body io.Read return nil, err } if res.StatusCode != wantStatus { + if res.StatusCode == 403 { + return nil, &AccessDeniedError{errors.New(errorMessageFromBody(slurp))} + } err := fmt.Errorf("HTTP %s: %s (expected %v)", res.Status, slurp, wantStatus) return nil, bestError(err, slurp) } diff --git a/cmd/tailscale/cli/cert.go b/cmd/tailscale/cli/cert.go index 650a69001..71c49ede6 100644 --- a/cmd/tailscale/cli/cert.go +++ b/cmd/tailscale/cli/cert.go @@ -13,6 +13,7 @@ "log" "net/http" "os" + "runtime" "strings" "github.com/peterbourgon/ff/v3/ffcli" @@ -85,6 +86,9 @@ func runCert(ctx context.Context, args []string) error { certArgs.keyFile = domain + ".key" } certPEM, keyPEM, err := tailscale.CertPair(ctx, domain) + if tailscale.IsAccessDeniedError(err) && os.Getuid() != 0 && runtime.GOOS != "windows" { + return fmt.Errorf("%v\n\nUse 'sudo tailscale cert' or 'tailscale up --operator=$USER' to not require root.", err) + } if err != nil { return err }