Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.43.0
)

require (
Expand Down Expand Up @@ -62,7 +63,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func RunLogin(f *cmdutil.Factory, opts *Options) error {
err error
)

if f.Interactive {
if f.Interactive && auth.CanCompleteBrowserLogin() {
f.Log.Info("A browser window will be opened for you to login, please confirm")
// get token from web
token, err := f.AuthClient.GenerateToken(context.Background())
Expand Down
8 changes: 5 additions & 3 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman

// require that the user is authenticated before running most commands
if cmdutil.IsAuthCheckEnabled(cmd) {
// in JSON mode, fail fast if not authenticated instead of opening a browser
if f.JSON && !f.LoggedIn() {
return fmt.Errorf("not authenticated: run `zeabur auth login` before using --json")
// JSON, -i=false, non-TTY, and headless Linux cannot finish
// the browser callback. OpenURL can still "succeed" and then
// WaitForToken blocks forever.
if !f.LoggedIn() && (f.JSON || !f.Interactive || !auth.CanCompleteBrowserLogin()) {
return fmt.Errorf("not authenticated: set ZEABUR_TOKEN or use --token, or run `zeabur auth login` from a terminal with a browser")
}

// do not return error, guide user to login instead
Expand Down
18 changes: 18 additions & 0 deletions pkg/auth/browser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package auth

import (
"os"

"golang.org/x/term"
)

// CanCompleteBrowserLogin reports whether we should start the implicit
// browser callback. /dev/null is a char device, so ModeCharDevice is the
// wrong test; term.IsTerminal is false for pipes and /dev/null.
//
// OpenURL can still return nil on this host (DISPLAY=:1, Chromium starts,
// dbus dies). Combined with a non-terminal stdin that is the hang:
// WaitForToken blocks forever.
func CanCompleteBrowserLogin() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
16 changes: 16 additions & 0 deletions pkg/auth/browser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package auth_test

import (
"os"
"testing"

"github.com/zeabur/cli/pkg/auth"
"golang.org/x/term"
)

func TestCanCompleteBrowserLogin_MatchesIsTerminal(t *testing.T) {
want := term.IsTerminal(int(os.Stdin.Fd()))
if got := auth.CanCompleteBrowserLogin(); got != want {
t.Fatalf("CanCompleteBrowserLogin() = %v, term.IsTerminal(stdin) = %v", got, want)
}
}
16 changes: 15 additions & 1 deletion pkg/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"encoding/hex"
"fmt"
"net/url"
"os"
"time"

"github.com/cli/browser"
)
Expand Down Expand Up @@ -60,11 +62,23 @@ func (c *ImplicitFlowClient) GenerateToken(ctx context.Context) (token string, e

endpoint.RawQuery = query.Encode()

// Always print the URL. On a headless Linux box OpenURL can return nil
// after launching a broken Chromium, then WaitForToken blocks forever.
fmt.Fprintf(os.Stderr, "Open this URL to log in: %s\n", endpoint.String())

if !CanCompleteBrowserLogin() {
return "", fmt.Errorf("cannot complete browser login (stdin is not a terminal); set ZEABUR_TOKEN or use --token (url=%s)", endpoint.String())
}

// Open the browser
if err := browser.OpenURL(endpoint.String()); err != nil {
return "", fmt.Errorf("failed to open browser (url=%s): %w", endpoint.String(), err)
}

// Bound the wait so a "successful" OpenURL on a broken display cannot hang.
ctx, stopWait := context.WithTimeout(ctx, 2*time.Minute)
defer stopWait()

// Wait for the token
tokenResponse, err := c.callbackServer.WaitForToken(ctx)
if err != nil {
Expand All @@ -85,4 +99,4 @@ func randomString(length int) (string, error) {
return "", err
}
return hex.EncodeToString(b), nil
}
}
18 changes: 15 additions & 3 deletions pkg/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,23 @@ func initViper(configPath string) {

func createConfigFile(configPath string) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
panic(fmt.Errorf("could not create config directory: %w", err))
}
if _, err := os.Create(configPath); err != nil {
f, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
panic(fmt.Errorf("could not create config file: %w", err))
}
if cerr := f.Close(); cerr != nil {
panic(fmt.Errorf("could not close config file: %w", cerr))
}
return
} else if err != nil {
panic(fmt.Errorf("could not stat config file: %w", err))
}
}
// Existing files were created with os.Create (0666 & umask = 0644).
// The YAML holds the access token; tighten it on every start.
if err := os.Chmod(configPath, 0o600); err != nil {
panic(fmt.Errorf("could not restrict config file mode: %w", err))
}
}
47 changes: 47 additions & 0 deletions pkg/config/init_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package config_test

import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/zeabur/cli/pkg/config"
)

func assertUnixMode(t *testing.T, path string, want os.FileMode) {
t.Helper()
if runtime.GOOS == "windows" {
return
}
st, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := st.Mode().Perm(); got != want {
t.Fatalf("%s mode = %o, want %o", path, got, want)
}
}

func TestNew_ConfigFileMode600(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "zeabur", "cli.yaml")
cfg := config.New(path)
assertUnixMode(t, path, 0o600)

cfg.SetTokenString("secret-token")
if err := cfg.Write(); err != nil {
t.Fatal(err)
}
assertUnixMode(t, path, 0o600)
}

func TestNew_TightensExisting0644(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cli.yaml")
if err := os.WriteFile(path, []byte("token: old\n"), 0o644); err != nil {
t.Fatal(err)
}
_ = config.New(path)
assertUnixMode(t, path, 0o600)
}
Loading