From e70e15ddb7b21f596edad2955ca00144934b8498 Mon Sep 17 00:00:00 2001 From: mobile-kevin <306153527+mobile-kevin@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:31:47 +0200 Subject: [PATCH 1/2] feat: send a mobilecli User-Agent on cloud requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mobilecli set no User-Agent, so every call it made to the cloud arrived as Go's default "Go-http-client/2.0" — indistinguishable from the autoscaler, internal pollers, and any other Go client in the fleet server access logs. There was no way to tell mobilecli usage apart from background traffic. mobilewright already identifies itself as "mobilewright/". Now every outbound request carries "mobilecli/": - REST calls to the fleet server (rpc.RESTCall) - the /ws JSON-RPC websocket handshake (rpc.Dial) - the device-code login posts to /login/device/code and /login/device/token, which are also an account-creation entry point Version moves from server/server.go to utils/version.go so the low-level rpc package can read it without an import cycle (server already imports rpc). The release workflow's sed targets move with it. Claude-Session: https://claude.ai/code/session_0158BtCgN4kKrzzGdiy2nadf --- .github/workflows/build.yml | 6 +++--- cli/auth.go | 18 ++++++++++++++++-- cli/root.go | 3 +-- rpc/rest.go | 3 +++ rpc/rpc.go | 2 ++ rpc/useragent_test.go | 34 ++++++++++++++++++++++++++++++++++ server/server.go | 4 +--- utils/version.go | 12 ++++++++++++ 8 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 rpc/useragent_test.go create mode 100644 utils/version.go diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3212be1e..37c99b04 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -191,7 +191,7 @@ jobs: echo "::error::Invalid release tag format: $GITHUB_REF_NAME" exit 1 fi - sed -i '' 's/Version = \"dev\"/Version = \"'"$GITHUB_REF_NAME"'\"/' server/server.go + sed -i '' 's/Version = \"dev\"/Version = \"'"$GITHUB_REF_NAME"'\"/' utils/version.go - name: Build run: | @@ -250,7 +250,7 @@ jobs: echo "::error::Invalid release tag format: $GITHUB_REF_NAME" exit 1 fi - sed -i 's/Version = \"dev\"/Version = \"'"$GITHUB_REF_NAME"'\"/' server/server.go + sed -i 's/Version = \"dev\"/Version = \"'"$GITHUB_REF_NAME"'\"/' utils/version.go - name: Build run: | @@ -304,7 +304,7 @@ jobs: Write-Error "Invalid release tag format: $env:GITHUB_REF_NAME" exit 1 } - sed -i ('s/Version = \"dev\"/Version = \"' + $env:GITHUB_REF_NAME + '\"/') server/server.go + sed -i ('s/Version = \"dev\"/Version = \"' + $env:GITHUB_REF_NAME + '\"/') utils/version.go - name: Build run: | diff --git a/cli/auth.go b/cli/auth.go index be776892..cdee2d06 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -11,6 +11,8 @@ import ( "github.com/spf13/cobra" "github.com/zalando/go-keyring" + + "github.com/mobile-next/mobilecli/utils" ) const ( @@ -73,9 +75,21 @@ var authLoginCmd = &cobra.Command{ }, } +// postJSON posts a JSON body to the auth server with mobilecli's User-Agent, so +// device-login traffic is attributable to the CLI in the server access logs. +func postJSON(url string, body []byte) (*http.Response, error) { + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", utils.UserAgent()) + return authHTTPClient.Do(req) +} + func requestDeviceCode() (*deviceCodeResponse, error) { reqBody, _ := json.Marshal(deviceCodeRequest{ClientID: deviceFlowClientID}) - resp, err := authHTTPClient.Post(deviceCodeURL, "application/json", bytes.NewReader(reqBody)) + resp, err := postJSON(deviceCodeURL, reqBody) if err != nil { return nil, fmt.Errorf("failed to request device code: %w", err) } @@ -117,7 +131,7 @@ func pollForToken(deviceCode string, interval, expiresIn int) (string, error) { DeviceCode: deviceCode, GrantType: deviceGrantType, }) - resp, err := authHTTPClient.Post(deviceTokenURL, "application/json", bytes.NewReader(reqBody)) + resp, err := postJSON(deviceTokenURL, reqBody) if err != nil { return "", fmt.Errorf("failed to poll for token: %w", err) } diff --git a/cli/root.go b/cli/root.go index 59d98f96..e0ddca65 100644 --- a/cli/root.go +++ b/cli/root.go @@ -6,7 +6,6 @@ import ( "log" "github.com/mobile-next/mobilecli/commands" - "github.com/mobile-next/mobilecli/server" "github.com/mobile-next/mobilecli/utils" "github.com/spf13/cobra" ) @@ -208,7 +207,7 @@ COMMON FLAGS: CompletionOptions: cobra.CompletionOptions{ HiddenDefaultCmd: true, }, - Version: server.Version, + Version: utils.Version, SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { diff --git a/rpc/rest.go b/rpc/rest.go index 5b8c5eaa..c27bc7a8 100644 --- a/rpc/rest.go +++ b/rpc/rest.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "time" + + "github.com/mobile-next/mobilecli/utils" ) // RESTTimeout is the deadline for a single REST call to the fleet server. @@ -59,6 +61,7 @@ func RESTCall(token, method, path string, body any, result any) error { return fmt.Errorf("failed to build request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", utils.UserAgent()) if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/rpc/rpc.go b/rpc/rpc.go index 1526952e..c2849ed0 100644 --- a/rpc/rpc.go +++ b/rpc/rpc.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/mobile-next/mobilecli/utils" ) type Request struct { @@ -62,6 +63,7 @@ func Dial(token string) (*websocket.Conn, error) { } header := http.Header{} header.Set("Authorization", "Bearer "+token) + header.Set("User-Agent", utils.UserAgent()) conn, _, err := fleetDialer.Dial(u.String(), header) return conn, err } diff --git a/rpc/useragent_test.go b/rpc/useragent_test.go new file mode 100644 index 00000000..d3ef0382 --- /dev/null +++ b/rpc/useragent_test.go @@ -0,0 +1,34 @@ +package rpc + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func restCallAgainstServerCapturingUserAgent(t *testing.T) string { + t.Helper() + + var seen string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("User-Agent") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + // GetAPIBaseURL derives the REST base from the fleet websocket URL. + t.Setenv("MOBILECLI_FLEET_URL", strings.Replace(server.URL, "http://", "ws://", 1)) + + if err := RESTCall("token", http.MethodGet, "/api/v1/sessions", nil, nil); err != nil { + t.Fatalf("RESTCall failed: %v", err) + } + return seen +} + +func TestRESTCallIdentifiesItselfAsMobilecli(t *testing.T) { + got := restCallAgainstServerCapturingUserAgent(t) + if !strings.HasPrefix(got, "mobilecli/") { + t.Errorf("expected a mobilecli User-Agent, got %q", got) + } +} diff --git a/server/server.go b/server/server.go index 194c8470..592123f9 100644 --- a/server/server.go +++ b/server/server.go @@ -50,8 +50,6 @@ const ( IdleTimeout = 120 * time.Second ) -var Version = "dev" - var okResponse = map[string]any{"status": "ok"} // StreamSession represents a screen capture streaming session @@ -1416,7 +1414,7 @@ func handleCrashesGet(params json.RawMessage) (any, error) { func handleServerInfo(params json.RawMessage) (any, error) { return map[string]string{ "name": "mobilecli", - "version": Version, + "version": utils.Version, }, nil } diff --git a/utils/version.go b/utils/version.go new file mode 100644 index 00000000..957bcb7b --- /dev/null +++ b/utils/version.go @@ -0,0 +1,12 @@ +package utils + +// Version is the mobilecli release version. The release workflow rewrites the +// literal below (see .github/workflows/build.yml); local builds stay "dev". +var Version = "dev" + +// UserAgent is the User-Agent mobilecli sends on every outbound HTTP and +// WebSocket request to the cloud. Without it Go sends "Go-http-client/2.0", +// which is indistinguishable from every other Go client in the access logs. +func UserAgent() string { + return "mobilecli/" + Version +} From 838c10970ac290e1003a00075640c24fb2996e7f Mon Sep 17 00:00:00 2001 From: mobile-kevin <306153527+mobile-kevin@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:39:05 +0200 Subject: [PATCH 2/2] fix: address lint findings in the User-Agent change - cli/auth.go: use http.NewRequestWithContext in postJSON (noctx) - rpc/useragent_test.go: check the w.Write error (errcheck) - rpc/useragent_test.go: assert the full mobilecli/ string rather than just the prefix, per review feedback golangci-lint now reports no new findings against main, and drops the two pre-existing client.Post noctx findings that postJSON replaced. --- cli/auth.go | 3 ++- rpc/useragent_test.go | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cli/auth.go b/cli/auth.go index cdee2d06..5281d15b 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -78,7 +79,7 @@ var authLoginCmd = &cobra.Command{ // postJSON posts a JSON body to the auth server with mobilecli's User-Agent, so // device-login traffic is attributable to the CLI in the server access logs. func postJSON(url string, body []byte) (*http.Response, error) { - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, err } diff --git a/rpc/useragent_test.go b/rpc/useragent_test.go index d3ef0382..6f840653 100644 --- a/rpc/useragent_test.go +++ b/rpc/useragent_test.go @@ -5,6 +5,8 @@ import ( "net/http/httptest" "strings" "testing" + + "github.com/mobile-next/mobilecli/utils" ) func restCallAgainstServerCapturingUserAgent(t *testing.T) string { @@ -13,7 +15,9 @@ func restCallAgainstServerCapturingUserAgent(t *testing.T) string { var seen string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r.Header.Get("User-Agent") - _, _ = w.Write([]byte(`{}`)) + if _, err := w.Write([]byte(`{}`)); err != nil { + t.Errorf("failed to write response: %v", err) + } })) defer server.Close() @@ -28,7 +32,8 @@ func restCallAgainstServerCapturingUserAgent(t *testing.T) string { func TestRESTCallIdentifiesItselfAsMobilecli(t *testing.T) { got := restCallAgainstServerCapturingUserAgent(t) - if !strings.HasPrefix(got, "mobilecli/") { - t.Errorf("expected a mobilecli User-Agent, got %q", got) + want := "mobilecli/" + utils.Version + if got != want { + t.Errorf("expected User-Agent %q, got %q", want, got) } }