diff --git a/cmd/auth/login.go b/cmd/auth/login.go index af1cb17..cc1d6bb 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,11 +5,13 @@ import ( "fmt" "os" "os/signal" + "strings" "time" "github.com/pterm/pterm" "github.com/urfave/cli/v2" + "github.com/NodeOps-app/createos-cli/internal/api" "github.com/NodeOps-app/createos-cli/internal/config" internaloauth "github.com/NodeOps-app/createos-cli/internal/oauth" "github.com/NodeOps-app/createos-cli/internal/terminal" @@ -42,11 +44,7 @@ func NewLoginCommand() *cli.Command { } // --token flag: API key flow (works in both TTY and non-TTY) if token := c.String("token"); token != "" { - if err := config.SaveToken(token); err != nil { - return fmt.Errorf("could not save your token: %w", err) - } - pterm.Success.Println("You're signed in.") - return nil + return loginWithAPIToken(c, token) } if c.Bool("device") { @@ -75,18 +73,30 @@ func NewLoginCommand() *cli.Command { return loginWithDevice(c) } if selected == options[2] { - return loginWithAPIToken() + return promptForAPIToken(c) } return loginWithBrowser() }, } } -func loginWithAPIToken() error { +func promptForAPIToken(c *cli.Context) error { token, err := pterm.DefaultInteractiveTextInput.WithMask("*").Show("Paste your API token") if err != nil || token == "" { return fmt.Errorf("sign in cancelled") } + return loginWithAPIToken(c, token) +} + +func loginWithAPIToken(c *cli.Context, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return fmt.Errorf("please provide an API token from your CreateOS dashboard") + } + client := api.NewClient(token, c.String("api-url"), c.Bool("debug")) + if _, err := client.GetUser(); err != nil { + return fmt.Errorf("could not verify your API token: %w", err) + } if err := config.SaveToken(token); err != nil { return fmt.Errorf("could not save your token: %w", err) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go new file mode 100644 index 0000000..2f85c04 --- /dev/null +++ b/cmd/auth/login_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/config" +) + +func TestLoginWithAPITokenValidatesBeforeSaving(t *testing.T) { + for _, tc := range []struct { + name string + token string + statusCode int + wantToken string + wantError string + }{ + {"valid", " valid-token ", http.StatusOK, "valid-token", ""}, + {"invalid", "abcd", http.StatusUnauthorized, "existing-token", "could not verify your API token"}, + {"server error", "valid-token", http.StatusServiceUnavailable, "existing-token", "could not verify your API token"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := config.SaveToken("existing-token"); err != nil { + t.Fatal(err) + } + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet || r.URL.Path != "/v1/users/me" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("X-Api-Key"); got != strings.TrimSpace(tc.token) { + t.Errorf("API key header = %q, want %q", got, strings.TrimSpace(tc.token)) + } + if got := r.UserAgent(); got != "createos-cli" { + t.Errorf("User-Agent = %q, want createos-cli", got) + } + w.WriteHeader(tc.statusCode) + if tc.statusCode == http.StatusOK { + _, _ = w.Write([]byte(`{"status":"success","data":{"id":"user-1"}}`)) + } else { + _, _ = w.Write([]byte(`{"status":"fail","data":"invalid api key"}`)) + } + })) + defer server.Close() + + app := &cli.App{ + Flags: []cli.Flag{&cli.StringFlag{Name: "api-url"}}, + Commands: []*cli.Command{NewLoginCommand()}, + } + err := app.Run([]string{"createos", "--api-url", server.URL, "login", "--token", tc.token}) + if tc.wantError == "" && err != nil { + t.Fatalf("login failed: %v", err) + } + if tc.wantError != "" && (err == nil || !strings.Contains(err.Error(), tc.wantError)) { + t.Fatalf("login error = %v, want %q", err, tc.wantError) + } + if requests != 1 { + t.Errorf("requests = %d, want 1", requests) + } + gotToken, err := config.LoadToken() + if err != nil { + t.Fatal(err) + } + if gotToken != tc.wantToken { + t.Errorf("saved token = %q, want %q", gotToken, tc.wantToken) + } + }) + } +} diff --git a/cmd/sandbox/mutagen_install.go b/cmd/sandbox/mutagen_install.go index 4b72185..ccaa3a8 100644 --- a/cmd/sandbox/mutagen_install.go +++ b/cmd/sandbox/mutagen_install.go @@ -17,6 +17,8 @@ import ( "path/filepath" "runtime" "time" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) // Mutagen version pinned by createos. Bump this when we want a newer @@ -163,6 +165,7 @@ func downloadAndExtractMutagen(url, ext, target string) error { if err != nil { return err } + httpclient.SetUserAgent(req) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("fetch %s: %w", url, err) diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go index a1ae3b7..47ab053 100644 --- a/cmd/sandbox/shell.go +++ b/cmd/sandbox/shell.go @@ -22,6 +22,7 @@ import ( "golang.org/x/term" "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/httpclient" "github.com/NodeOps-app/createos-cli/internal/terminal" ) @@ -423,6 +424,7 @@ func dialControlUpgrade(ctx context.Context, ctrlURL, authHeader, token, path st req := "POST " + path + " HTTP/1.1\r\n" + "Host: " + u.Host + "\r\n" + authHeader + ": " + token + "\r\n" + + "User-Agent: " + httpclient.UserAgent + "\r\n" + "Connection: Upgrade\r\n" + "Upgrade: tcp-tunnel\r\n" + "Content-Length: 0\r\n\r\n" @@ -616,8 +618,9 @@ func dialControlTunnel(ctx context.Context, ctrlURL, authHeader, token, id strin req := fmt.Sprintf("POST /v1/sandboxes/%s/tunnel/%d HTTP/1.1\r\n"+ "Host: %s\r\n%s: %s\r\n"+ + "User-Agent: %s\r\n"+ "Connection: Upgrade\r\nUpgrade: tcp-tunnel\r\nContent-Length: 0\r\n\r\n", - id, port, u.Host, authHeader, token) + id, port, u.Host, authHeader, token, httpclient.UserAgent) if _, err = conn.Write([]byte(req)); err != nil { _ = conn.Close() //nolint:errcheck return nil, err diff --git a/cmd/templates/use.go b/cmd/templates/use.go index 4a35a64..8914b99 100644 --- a/cmd/templates/use.go +++ b/cmd/templates/use.go @@ -12,6 +12,7 @@ import ( "github.com/urfave/cli/v2" "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/httpclient" "github.com/NodeOps-app/createos-cli/internal/terminal" ) @@ -112,6 +113,7 @@ func newTemplatesUseCommand() *cli.Command { if err != nil { return fmt.Errorf("could not create download request: %w", err) } + httpclient.SetUserAgent(req) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("could not download template: %w", err) diff --git a/cmd/upgrade/upgrade.go b/cmd/upgrade/upgrade.go index 52c9ff0..c92fdaa 100644 --- a/cmd/upgrade/upgrade.go +++ b/cmd/upgrade/upgrade.go @@ -19,6 +19,7 @@ import ( "github.com/urfave/cli/v2" "golang.org/x/mod/semver" + "github.com/NodeOps-app/createos-cli/internal/httpclient" "github.com/NodeOps-app/createos-cli/internal/pkg/version" ) @@ -163,6 +164,7 @@ func fetchLatestRelease() (*githubRelease, error) { if err != nil { return nil, err } + httpclient.SetUserAgent(req) req.Header.Set("Accept", "application/vnd.github+json") resp, err := httpClient.Do(req) @@ -233,6 +235,7 @@ func downloadToTemp(rawURL, assetName string) (string, error) { if err != nil { return "", err } + httpclient.SetUserAgent(req) client := &http.Client{Timeout: downloadTimeout} resp, err := client.Do(req) @@ -304,6 +307,7 @@ func fetchNightlyCommit(release *githubRelease) (string, error) { if err != nil { return "", err } + httpclient.SetUserAgent(req) resp, err := httpClient.Do(req) if err != nil { @@ -338,6 +342,7 @@ func fetchChecksum(rawURL string) (string, error) { if err != nil { return "", err } + httpclient.SetUserAgent(req) resp, err := httpClient.Do(req) if err != nil { diff --git a/internal/api/client.go b/internal/api/client.go index 30516d2..fe64db2 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/go-resty/resty/v2" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) // TokenRefresher obtains a fresh access token after the server rejects @@ -80,6 +82,7 @@ func NewClient(token, apiURL string, debug bool) APIClient { client := resty.New(). SetBaseURL(apiURL). + SetHeader("User-Agent", httpclient.UserAgent). SetHeader(headerAPIKey, token). SetHeader("Content-Type", "application/json") @@ -104,6 +107,7 @@ func NewClientWithAccessToken(accessToken, apiURL string, debug bool, refresher client := resty.New(). SetBaseURL(apiURL). + SetHeader("User-Agent", httpclient.UserAgent). SetHeader(headerAccessToken, accessToken). SetHeader("Content-Type", "application/json") diff --git a/internal/api/client_test.go b/internal/api/client_test.go new file mode 100644 index 0000000..f4c87d2 --- /dev/null +++ b/internal/api/client_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-resty/resty/v2" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" +) + +func TestClientsSendUserAgent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.UserAgent(); got != httpclient.UserAgent { + t.Errorf("User-Agent = %q, want %q", got, httpclient.UserAgent) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + clients := map[string]*resty.Client{ + "API key": NewClient("key", server.URL, false).Client, + "OAuth": NewClientWithAccessToken("token", server.URL, false, nil).Client, + "sandbox API key": NewSandboxClient("key", server.URL, false).Client, + "sandbox OAuth": NewSandboxClientWithAccessToken("token", server.URL, false, nil).Client, + } + for name, client := range clients { + t.Run(name, func(t *testing.T) { + if _, err := client.R().Get("/"); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/api/sandbox_client.go b/internal/api/sandbox_client.go index 98c41a8..ef92cd9 100644 --- a/internal/api/sandbox_client.go +++ b/internal/api/sandbox_client.go @@ -2,6 +2,8 @@ package api import ( "github.com/go-resty/resty/v2" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) // DefaultSandboxBaseURL is the default fc-spawn API base URL. The @@ -58,6 +60,7 @@ func newSandboxClient(authHeader, token, sandboxURL string, debug bool, refreshe } client := resty.New(). SetBaseURL(sandboxURL). + SetHeader("User-Agent", httpclient.UserAgent). SetHeader(authHeader, token). SetHeader("Content-Type", "application/json") if debug { diff --git a/internal/httpclient/agent.go b/internal/httpclient/agent.go new file mode 100644 index 0000000..2442a8c --- /dev/null +++ b/internal/httpclient/agent.go @@ -0,0 +1,11 @@ +package httpclient + +import "net/http" + +// UserAgent is sent with HTTP requests made by the CLI. +const UserAgent = "createos-cli" + +// SetUserAgent identifies HTTP requests made by the CLI. +func SetUserAgent(req *http.Request) { + req.Header.Set("User-Agent", UserAgent) +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 4fd85e1..7642465 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -11,6 +11,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) // InstallScope represents the scope of a skill install (local or global). @@ -74,6 +76,7 @@ func InstallToScope(downloadURL, uniqueName string, scope InstallScope) ([]strin if err != nil { return nil, fmt.Errorf("download request failed: %w", err) } + httpclient.SetUserAgent(req) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("download failed: %w", err) diff --git a/internal/oauth/device.go b/internal/oauth/device.go index 33e7f4f..c9bb9a2 100644 --- a/internal/oauth/device.go +++ b/internal/oauth/device.go @@ -10,6 +10,8 @@ import ( "net/url" "strings" "time" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" @@ -118,6 +120,7 @@ func devicePost(ctx context.Context, endpoint string, form url.Values) (int, []b if err != nil { return 0, nil, fmt.Errorf("could not start sign in — run 'createos login' to try again") } + httpclient.SetUserAgent(req) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 577a9a4..1a8f5cc 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -15,6 +15,8 @@ import ( "runtime" "strings" "time" + + "github.com/NodeOps-app/createos-cli/internal/httpclient" ) // ServerMetadata holds the OAuth authorization server metadata (RFC 8414) @@ -50,6 +52,7 @@ func FetchServerMetadataContext(ctx context.Context, baseURL string) (*ServerMet if err != nil { return nil, fmt.Errorf("could not reach authorization server") } + httpclient.SetUserAgent(req) resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req) if err != nil { return nil, fmt.Errorf("could not reach authorization server — check your internet connection") @@ -112,6 +115,7 @@ func ExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) ( if err != nil { return nil, fmt.Errorf("login failed — could not initiate token exchange") } + httpclient.SetUserAgent(req) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -139,6 +143,7 @@ func RefreshTokens(tokenEndpoint, clientID, refreshToken string) (*TokenResponse if err != nil { return nil, fmt.Errorf("session refresh failed — could not initiate request") } + httpclient.SetUserAgent(req) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/internal/updater/updater.go b/internal/updater/updater.go index 57464a2..cd802d9 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -11,6 +11,7 @@ import ( "golang.org/x/mod/semver" + "github.com/NodeOps-app/createos-cli/internal/httpclient" "github.com/NodeOps-app/createos-cli/internal/pkg/version" ) @@ -112,6 +113,7 @@ func fetchLatest() string { if err != nil { return "" } + httpclient.SetUserAgent(req) req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req)