diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 1d159275..0f17000a 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -16,7 +16,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" ) // configInitResult holds the result of the interactive config init flow. @@ -179,7 +179,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor // Step 1: Request app registration (begin) // Use the shared proxy-plugin-aware transport so registration traffic is not // a bypass of proxy plugin mode. - httpClient := util.NewHTTPClient(0) + httpClient := transport.NewHTTPClient(0) authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) if err != nil { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err) diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index f96cdb6a..2a5265b1 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -19,8 +19,8 @@ import ( "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/identitydiag" "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/update" - "github.com/larksuite/cli/internal/util" ) // DoctorOptions holds inputs for the doctor command. @@ -155,7 +155,7 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) // Use the shared proxy-plugin-aware transport so connectivity checks reflect // the real egress path (and are blocked when proxy plugin fails closed). - httpClient := util.NewHTTPClient(0) + httpClient := transport.NewHTTPClient(0) mcpURL := ep.MCP + "/mcp" type probeResult struct { diff --git a/internal/auth/transport.go b/internal/auth/transport.go index b6d42289..95b2c6cd 100644 --- a/internal/auth/transport.go +++ b/internal/auth/transport.go @@ -14,7 +14,7 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/errclass" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" ) // SecurityPolicyTransport is an http.RoundTripper that intercepts all responses @@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } // RoundTrip implements http.RoundTripper. diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index f18a816b..514aaf93 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -23,7 +23,7 @@ import ( "github.com/larksuite/cli/internal/keychain" "github.com/larksuite/cli/internal/registry" _ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" _ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider ) @@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - util.WarnIfProxied(f.IOStreams.ErrOut) + transport.WarnIfProxied(f.IOStreams.ErrOut) - var transport http.RoundTripper = util.SharedTransport() - transport = &RetryTransport{Base: transport} - transport = &SecurityHeaderTransport{Base: transport} - transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor - transport = wrapWithExtension(transport) + var rt http.RoundTripper = transport.Shared() + rt = &RetryTransport{Base: rt} + rt = &SecurityHeaderTransport{Base: rt} + rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor + rt = wrapWithExtension(rt) client := &http.Client{ - Transport: transport, + Transport: rt, Timeout: 30 * time.Second, CheckRedirect: safeRedirectPolicy, } @@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - util.WarnIfProxied(f.IOStreams.ErrOut) + transport.WarnIfProxied(f.IOStreams.ErrOut) opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, @@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { } func buildSDKTransport() http.RoundTripper { - var sdkTransport http.RoundTripper = util.SharedTransport() + var sdkTransport http.RoundTripper = transport.Shared() sdkTransport = &RetryTransport{Base: sdkTransport} sdkTransport = &UserAgentTransport{Base: sdkTransport} sdkTransport = &BuildHeaderTransport{Base: sdkTransport} diff --git a/internal/cmdutil/transport.go b/internal/cmdutil/transport.go index ccb66119..351eeb85 100644 --- a/internal/cmdutil/transport.go +++ b/internal/cmdutil/transport.go @@ -9,7 +9,7 @@ import ( "time" exttransport "github.com/larksuite/cli/extension/transport" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" ) // RetryTransport is an http.RoundTripper that retries on 5xx responses @@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } func (t *RetryTransport) delay() time.Duration { @@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error if t.Base != nil { return t.Base.RoundTrip(req) } - return util.FallbackTransport().RoundTrip(req) + return transport.Fallback().RoundTrip(req) } // BuildHeaderTransport is an http.RoundTripper that force-writes the @@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err if t.Base != nil { return t.Base.RoundTrip(req) } - return util.FallbackTransport().RoundTrip(req) + return transport.Fallback().RoundTrip(req) } // SecurityHeaderTransport is an http.RoundTripper that injects CLI security @@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper { if t.Base != nil { return t.Base } - return util.FallbackTransport() + return transport.Fallback() } // RoundTrip implements http.RoundTripper. diff --git a/internal/cmdutil/transport_test.go b/internal/cmdutil/transport_test.go index f76b0c32..9cd06102 100644 --- a/internal/cmdutil/transport_test.go +++ b/internal/cmdutil/transport_test.go @@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) { // TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil, // the transport still sets X-Cli-Build and routes the request through -// util.FallbackTransport rather than panicking. This covers the fallback +// transport.Fallback rather than panicking. This covers the fallback // branch in RoundTrip that is otherwise unreachable with a non-nil Base. func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) { var receivedBuild string diff --git a/internal/registry/remote.go b/internal/registry/remote.go index 4bc9d6a8..e13ec66d 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,7 +17,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -181,7 +181,7 @@ func saveCachedMerged(data []byte, meta CacheMeta) error { func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) { // Route through the shared proxy-plugin-aware transport so remote API // definition fetches honor proxy plugin mode instead of bypassing it. - client := util.NewHTTPClient(fetchTimeout) + client := transport.NewHTTPClient(fetchTimeout) req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil) if err != nil { return nil, nil, err diff --git a/internal/proxyplugin/config.go b/internal/transport/config.go similarity index 90% rename from internal/proxyplugin/config.go rename to internal/transport/config.go index 46c160d1..009e5f2c 100644 --- a/internal/proxyplugin/config.go +++ b/internal/transport/config.go @@ -1,14 +1,15 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package proxyplugin implements the ~/.lark-cli/proxy_config.json based security proxy plugin mode. +// Package transport owns how the CLI assembles its outbound HTTP transport: the +// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY +// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode. // -// It supports: -// - forcing all outbound HTTP(S) requests through a fixed HTTP proxy -// - trusting an additional root CA PEM bundle for MITM/inspection proxies -// -// Environment variables override matching values from proxy_config.json. -package proxyplugin +// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback +// proxy, optionally trusting an extra root CA PEM bundle for TLS-inspection +// proxies, and fails closed on misconfiguration. Environment variables override +// matching values from proxy_config.json. +package transport import ( "encoding/json" @@ -222,21 +223,6 @@ func (c *Config) proxyURL() (*url.URL, error) { return u, nil } -// redactProxyURL masks userinfo (username:password) in a proxy URL. -// Handles both scheme-prefixed ("http://user:pass@host") and bare formats. -func redactProxyURL(raw string) string { - u, err := url.Parse(raw) - if err == nil && u.User != nil { - u.User = url.User("***") - return u.String() - } - // Fallback: handle "user:pass@proxy:8080" - if at := strings.LastIndex(raw, "@"); at > 0 { - return "***@" + raw[at+1:] - } - return raw -} - // ApplyToTransport clones base and applies proxy plugin settings to the clone. // Caller owns the returned *http.Transport. func (c *Config) ApplyToTransport(base *http.Transport) (*http.Transport, error) { diff --git a/internal/proxyplugin/config_test.go b/internal/transport/config_test.go similarity index 99% rename from internal/proxyplugin/config_test.go rename to internal/transport/config_test.go index c17c3a49..24159950 100644 --- a/internal/proxyplugin/config_test.go +++ b/internal/transport/config_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package proxyplugin +package transport import ( "net/http" diff --git a/internal/transport/shared.go b/internal/transport/shared.go new file mode 100644 index 00000000..9fb7041d --- /dev/null +++ b/internal/transport/shared.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "net/http" + "os" + "sync" + "time" +) + +// Shared returns the base http.RoundTripper for all CLI HTTP clients. +// +// Precedence (highest first): +// 1. proxy-plugin mode — force traffic through a fixed loopback proxy; +// FAIL-CLOSED when the plugin config exists but is invalid. +// 2. LARK_CLI_NO_PROXY — direct egress, proxy disabled. +// 3. http.DefaultTransport — the stdlib process-wide singleton (honors +// HTTP(S)_PROXY), so every client shares one connection pool / TLS cache. +// +// The returned RoundTripper MUST NOT be mutated. Callers that need a customized +// transport should assert to *http.Transport and Clone() it. A shared base is +// required so persistConn read/write goroutines are reused; cloning per call +// leaks them until IdleConnTimeout (~90s) fires. +func Shared() http.RoundTripper { + // Proxy-plugin mode overrides everything, INCLUDING LARK_CLI_NO_PROXY. When + // the plugin config exists but is invalid, pluginTransport returns a + // fail-closed transport with ok=true and we return it here — we MUST NOT + // fall through to the NO_PROXY / DefaultTransport direct-egress paths below. + if t, ok := pluginTransport(); ok { + return t + } + if os.Getenv(EnvNoProxy) != "" { + return noProxyTransport() + } + return http.DefaultTransport +} + +// Fallback returns a shared *http.Transport. It is a thin wrapper over Shared +// retained so modules already on the leak-free singleton path (internal/auth, +// internal/cmdutil transport decorators) do not have to migrate. New code +// should prefer Shared and treat the base as an http.RoundTripper. +// +// Fail-closed invariant: pluginTransport always expresses its blocked transport +// as a concrete *http.Transport (see failClosedTransport), so the assertion +// below preserves the block. The noProxyTransport() fallback is therefore only +// reached when no proxy plugin is configured and some external code replaced +// http.DefaultTransport with a non-*http.Transport — a case with no fail-closed +// intent, where a proxy-disabled transport is acceptable. +func Fallback() *http.Transport { + if t, ok := Shared().(*http.Transport); ok { + return t + } + return noProxyTransport() +} + +// NewHTTPClient returns an *http.Client whose Transport is the shared, +// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{} +// for outbound requests: a bare client falls back to http.DefaultTransport and +// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or +// fail-closed), creating an audit blind spot. +// +// A zero timeout means no client-level timeout (callers relying on context +// deadlines pass 0). +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: Shared(), + Timeout: timeout, + } +} + +// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily +// built the first time LARK_CLI_NO_PROXY is observed set. +var noProxyTransport = sync.OnceValue(func() *http.Transport { + def, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return &http.Transport{} + } + t := def.Clone() + t.Proxy = nil + return t +}) diff --git a/internal/transport/shared_test.go b/internal/transport/shared_test.go new file mode 100644 index 00000000..fe960034 --- /dev/null +++ b/internal/transport/shared_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "net/http" + "net/url" + "testing" + "time" +) + +// TestShared_DefaultReturnsStdlibSingleton verifies the default shared transport. +func TestShared_DefaultReturnsStdlibSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "") + if Shared() != http.DefaultTransport { + t.Error("Shared should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset") + } +} + +// TestShared_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport. +func TestShared_NoProxyReturnsClone(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "1") + tr := Shared() + if tr == http.DefaultTransport { + t.Fatal("Shared should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set") + } + ht, ok := tr.(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", tr) + } + if ht.Proxy != nil { + t.Error("no-proxy transport should have Proxy == nil") + } +} + +// TestShared_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport. +func TestShared_NoProxyIsCachedSingleton(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "1") + if Shared() != Shared() { + t.Error("repeated Shared calls with LARK_CLI_NO_PROXY set must return the same instance") + } +} + +// TestShared_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib +// transport after unsetting LARK_CLI_NO_PROXY. +func TestShared_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating + // the no-proxy singleton), then unsets it. Subsequent calls must return + // http.DefaultTransport, NOT the cached no-proxy clone. + t.Setenv(EnvNoProxy, "1") + if Shared() == http.DefaultTransport { + t.Fatal("precondition: first call with env set should not return DefaultTransport") + } + + t.Setenv(EnvNoProxy, "") + if after := Shared(); after != http.DefaultTransport { + t.Errorf("after unsetting LARK_CLI_NO_PROXY, Shared must return http.DefaultTransport, got %T", after) + } +} + +// TestShared_NoProxyOverridesSystemProxy verifies that LARK_CLI_NO_PROXY disables system proxies. +func TestShared_NoProxyOverridesSystemProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") + t.Setenv(EnvNoProxy, "1") + + ht, ok := Shared().(*http.Transport) + if !ok { + t.Fatalf("expected *http.Transport, got %T", Shared()) + } + if ht.Proxy != nil { + t.Error("LARK_CLI_NO_PROXY should override system proxy settings") + } +} + +// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware +// transport (instead of a bare client that bypasses proxy plugin mode). +func TestNewHTTPClient(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + t.Setenv(EnvNoProxy, "") + + c := NewHTTPClient(7 * time.Second) + if c.Transport == nil { + t.Fatal("NewHTTPClient transport is nil; want shared transport") + } + if c.Transport != Shared() { + t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport) + } + if c.Timeout != 7*time.Second { + t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout) + } +} + +// TestShared_PluginOverridesNoProxy locks the contract that proxy-plugin mode wins +// over LARK_CLI_NO_PROXY: even with NO_PROXY set, an enabled plugin forces the proxy. +func TestShared_PluginOverridesNoProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + t.Setenv(EnvNoProxy, "1") // NO_PROXY set, but the plugin must win + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{ + "LARKSUITE_CLI_PROXY_ENABLE": true, + "LARKSUITE_CLI_PROXY_ADDRESS": "http://127.0.0.1:3128" +}`), 0600) + + tr, ok := Shared().(*http.Transport) + if !ok { + t.Fatalf("Shared() = %T, want proxy *http.Transport, not the NO_PROXY clone", tr) + } + u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err != nil || u == nil || u.String() != "http://127.0.0.1:3128" { + t.Fatalf("Proxy() = %v, %v; plugin must override NO_PROXY with the fixed proxy", u, err) + } +} + +// TestShared_MalformedConfigFailsClosedEvenWithNoProxy locks the most dangerous +// invariant of the fold: a malformed proxy_config.json must FAIL CLOSED, never +// fall through to direct egress — not even to the LARK_CLI_NO_PROXY clone. +func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + t.Setenv(EnvNoProxy, "1") + resetProxyPluginState() + + writeFile(t, Path(), []byte(`{`), 0600) // malformed + + rt := Shared() + if rt == http.DefaultTransport { + t.Fatal("malformed config returned http.DefaultTransport — fail OPEN") + } + if rt == noProxyTransport() { + t.Fatal("malformed config fell through to the NO_PROXY direct-egress clone — fail OPEN") + } + resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) + if err == nil { + t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp) + } +} diff --git a/internal/proxyplugin/tls_ca.go b/internal/transport/tls_ca.go similarity index 99% rename from internal/proxyplugin/tls_ca.go rename to internal/transport/tls_ca.go index c55dd796..079cb6f1 100644 --- a/internal/proxyplugin/tls_ca.go +++ b/internal/transport/tls_ca.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package proxyplugin +package transport import ( "crypto/tls" diff --git a/internal/proxyplugin/tls_ca_test.go b/internal/transport/tls_ca_test.go similarity index 99% rename from internal/proxyplugin/tls_ca_test.go rename to internal/transport/tls_ca_test.go index 31e25c23..9fd1b9e7 100644 --- a/internal/proxyplugin/tls_ca_test.go +++ b/internal/transport/tls_ca_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package proxyplugin +package transport import ( "crypto/rand" diff --git a/internal/proxyplugin/transport.go b/internal/transport/transport.go similarity index 89% rename from internal/proxyplugin/transport.go rename to internal/transport/transport.go index 9179c468..88b2b116 100644 --- a/internal/proxyplugin/transport.go +++ b/internal/transport/transport.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package proxyplugin +package transport import ( "fmt" @@ -16,7 +16,7 @@ var proxyPluginTransport = sync.OnceValue(buildProxyPluginTransport) // cachedBlockedTransport is a fail-closed transport cached on first use when // the proxy plugin config exists but is invalid. This avoids cloning -// http.DefaultTransport on every SharedTransport call. +// http.DefaultTransport on every pluginTransport call. var cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) func buildBlockedTransport() http.RoundTripper { @@ -28,7 +28,7 @@ func buildProxyPluginTransport() http.RoundTripper { if !ok { // Cannot clone the stdlib transport. Fail closed with a concrete // *http.Transport (not a bare RoundTripper) so downcasting callers such - // as util.FallbackTransport cannot silently degrade this into a + // as Fallback cannot silently degrade this into a // direct-egress transport. return failClosedTransport(fmt.Errorf("proxy plugin transport unavailable: http.DefaultTransport is %T, want *http.Transport", http.DefaultTransport)) } @@ -51,9 +51,9 @@ func buildProxyPluginTransport() http.RoundTripper { return t } -// SharedTransport returns the proxy plugin transport when proxy plugin mode is +// pluginTransport returns the proxy plugin transport when proxy plugin mode is // configured. The bool return is false when the plugin is not configured or not enabled. -func SharedTransport() (http.RoundTripper, bool) { +func pluginTransport() (http.RoundTripper, bool) { cfg, err := Load() if err != nil { return cachedBlockedTransport(), true @@ -68,7 +68,7 @@ func SharedTransport() (http.RoundTripper, bool) { // err. It clones http.DefaultTransport when possible (preserving dial/timeout // tuning); otherwise it builds a minimal transport. Returning a concrete // *http.Transport (rather than a bare RoundTripper) is required so downcasting -// callers such as util.FallbackTransport cannot silently degrade a fail-closed +// callers such as Fallback cannot silently degrade a fail-closed // signal into a direct-egress transport. func failClosedTransport(err error) *http.Transport { if def, ok := http.DefaultTransport.(*http.Transport); ok { diff --git a/internal/proxyplugin/transport_test.go b/internal/transport/transport_test.go similarity index 76% rename from internal/proxyplugin/transport_test.go rename to internal/transport/transport_test.go index c4414f8b..ad6d8987 100644 --- a/internal/proxyplugin/transport_test.go +++ b/internal/transport/transport_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package proxyplugin +package transport import ( "io" @@ -20,21 +20,21 @@ func resetProxyPluginState() { cachedBlockedTransport = sync.OnceValue(buildBlockedTransport) } -func TestSharedTransport_NotConfigured(t *testing.T) { +func TestPluginTransport_NotConfigured(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) resetProxyPluginState() - tr, ok := SharedTransport() + tr, ok := pluginTransport() if ok { - t.Fatalf("SharedTransport() ok = true, want false") + t.Fatalf("pluginTransport() ok = true, want false") } if tr != nil { - t.Fatalf("SharedTransport() transport = %T, want nil", tr) + t.Fatalf("pluginTransport() transport = %T, want nil", tr) } } -func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) { +func TestPluginTransport_EnabledReturnsFixedProxy(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) resetProxyPluginState() @@ -46,13 +46,13 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) { "LARKSUITE_CLI_CA_PATH": "" }`), 0600) - rt, ok := SharedTransport() + rt, ok := pluginTransport() if !ok { - t.Fatal("SharedTransport() ok = false, want true") + t.Fatal("pluginTransport() ok = false, want true") } tr, ok := rt.(*http.Transport) if !ok { - t.Fatalf("SharedTransport() = %T, want *http.Transport", rt) + t.Fatalf("pluginTransport() = %T, want *http.Transport", rt) } u, err := tr.Proxy(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) if err != nil { @@ -63,7 +63,7 @@ func TestSharedTransport_EnabledReturnsFixedProxy(t *testing.T) { } } -func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) { +func TestPluginTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) resetProxyPluginState() @@ -72,12 +72,12 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test writeFile(t, Path(), []byte(`{`), 0600) - rt, ok := SharedTransport() + rt, ok := pluginTransport() if !ok { - t.Fatal("SharedTransport() ok = false, want true") + t.Fatal("pluginTransport() ok = false, want true") } if rt == http.DefaultTransport { - t.Fatalf("SharedTransport() returned http.DefaultTransport, want fail-closed transport") + t.Fatalf("pluginTransport() returned http.DefaultTransport, want fail-closed transport") } resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) if err == nil { @@ -88,23 +88,23 @@ func TestSharedTransport_InvalidConfigWithNonTransportDefaultFailsClosed(t *test } } -func TestSharedTransport_InvalidConfigReturnsCachedInstance(t *testing.T) { +func TestPluginTransport_InvalidConfigReturnsCachedInstance(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) resetProxyPluginState() writeFile(t, Path(), []byte(`{`), 0600) - a, ok := SharedTransport() + a, ok := pluginTransport() if !ok { - t.Fatal("SharedTransport() ok = false, want true") + t.Fatal("pluginTransport() ok = false, want true") } - b, ok := SharedTransport() + b, ok := pluginTransport() if !ok { - t.Fatal("SharedTransport() ok = false, want true") + t.Fatal("pluginTransport() ok = false, want true") } if a != b { - t.Fatalf("SharedTransport() returned different instances on repeated calls; blocked transport must be cached") + t.Fatalf("pluginTransport() returned different instances on repeated calls; blocked transport must be cached") } } @@ -148,13 +148,13 @@ func TestBuildProxyPluginTransport_NonTransportDefaultFailsClosed(t *testing.T) } } -// TestSharedTransport_InvalidConfigBlockerIsConcreteTransport guards the -// fail-closed invariant that util.FallbackTransport relies on: even when +// TestPluginTransport_InvalidConfigBlockerIsConcreteTransport guards the +// fail-closed invariant that Fallback relies on: even when // http.DefaultTransport is not an *http.Transport, an invalid proxy config must // produce a blocked transport that is itself a concrete *http.Transport. If it -// were a bare RoundTripper, util.FallbackTransport would downcast-fail and +// were a bare RoundTripper, Fallback would downcast-fail and // silently degrade it into a direct-egress transport. -func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) { +func TestPluginTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) resetProxyPluginState() @@ -163,12 +163,12 @@ func TestSharedTransport_InvalidConfigBlockerIsConcreteTransport(t *testing.T) { writeFile(t, Path(), []byte(`{`), 0600) - rt, ok := SharedTransport() + rt, ok := pluginTransport() if !ok { - t.Fatal("SharedTransport() ok = false, want true") + t.Fatal("pluginTransport() ok = false, want true") } if _, isTransport := rt.(*http.Transport); !isTransport { - t.Fatalf("SharedTransport() blocked transport = %T, want *http.Transport so FallbackTransport cannot degrade it to direct egress", rt) + t.Fatalf("pluginTransport() blocked transport = %T, want *http.Transport so Fallback cannot degrade it to direct egress", rt) } // Must remain fail-closed. resp, err := rt.RoundTrip(&http.Request{URL: &url.URL{Scheme: "https", Host: "open.feishu.cn"}}) diff --git a/internal/transport/warn.go b/internal/transport/warn.go new file mode 100644 index 00000000..cac050f7 --- /dev/null +++ b/internal/transport/warn.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package transport + +import ( + "fmt" + "io" + "net/url" + "os" + "strings" + "sync" + + "github.com/larksuite/cli/internal/envvars" +) + +// Proxy environment constants control shared transport proxy behavior. +const ( + // EnvNoProxy disables automatic proxy support when set to any non-empty value. + EnvNoProxy = "LARK_CLI_NO_PROXY" +) + +// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads. +var proxyEnvKeys = []string{ + "HTTPS_PROXY", "https_proxy", + "HTTP_PROXY", "http_proxy", + "ALL_PROXY", "all_proxy", +} + +// DetectProxyEnv returns the first proxy-related environment variable that is set, +// or empty strings if none are configured. +func DetectProxyEnv() (key, value string) { + for _, k := range proxyEnvKeys { + if v := os.Getenv(k); v != "" { + return k, v + } + } + return "", "" +} + +// proxyWarningOnce ensures proxy environment warnings are emitted at most once. +var proxyWarningOnce sync.Once + +// proxyPluginStatus reports the configured proxy plugin address, the extra +// trusted CA path (if any), and whether proxy plugin mode is enabled. It is +// indirected through a package variable so tests can simulate plugin-enabled +// mode without the process-global Load() sync.Once cache. +var proxyPluginStatus = func() (addr, caPath string, enabled bool) { + cfg, err := Load() + if err != nil || !cfg.Enabled() { + return "", "", false + } + return cfg.Proxy, cfg.CAPath, true +} + +// redactProxyURL masks userinfo (username:password) in a proxy URL. +// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats. +func redactProxyURL(raw string) string { + // Try standard url.Parse first (works when scheme is present) + u, err := url.Parse(raw) + if err == nil && u.User != nil { + return u.Scheme + "://***@" + u.Host + u.RequestURI() + } + + // Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080") + if at := strings.LastIndex(raw, "@"); at > 0 { + return "***@" + raw[at+1:] + } + + return raw +} + +// WarnIfProxied prints a one-time warning to w when a proxy environment variable +// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials +// are redacted. Safe to call multiple times; only the first call prints. +func WarnIfProxied(w io.Writer) { + proxyWarningOnce.Do(func() { + // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see + // Shared), so its warning and disable instructions take precedence. + // Emitting the env-proxy warning here would be misleading: it tells the + // user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy. + if addr, caPath, enabled := proxyPluginStatus(); enabled { + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n", + redactProxyURL(addr), envvars.CliProxyEnable, Path()) + if strings.TrimSpace(caPath) != "" { + // A custom CA means upstream TLS can be intercepted/inspected by + // the proxy (MITM). Surface it so the operator is aware traffic + // (including Bearer tokens) is decryptable on this host. + fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n", + caPath) + } + return + } + if os.Getenv(EnvNoProxy) != "" { + return + } + key, val := DetectProxyEnv() + if key == "" { + return + } + fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n", + key, redactProxyURL(val), EnvNoProxy) + }) +} diff --git a/internal/util/proxy_test.go b/internal/transport/warn_test.go similarity index 63% rename from internal/util/proxy_test.go rename to internal/transport/warn_test.go index ae23215e..13708ca7 100644 --- a/internal/util/proxy_test.go +++ b/internal/transport/warn_test.go @@ -1,44 +1,17 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package util +package transport import ( "bytes" - "net/http" - "os" "strings" "sync" "testing" - "time" "github.com/larksuite/cli/internal/envvars" ) -// unsetEnv clears key for the duration of the test and restores its original value. -func unsetEnv(t *testing.T, key string) { - t.Helper() - old, had := os.LookupEnv(key) - _ = os.Unsetenv(key) - t.Cleanup(func() { - if had { - _ = os.Setenv(key, old) - } else { - _ = os.Unsetenv(key) - } - }) -} - -// unsetProxyPluginEnv clears proxy-related environment variables for deterministic tests. -func unsetProxyPluginEnv(t *testing.T) { - t.Helper() - // Ensure developer machine env doesn't accidentally enable proxy plugin mode - // and change expectations for SharedTransport(). - unsetEnv(t, envvars.CliProxyEnable) - unsetEnv(t, envvars.CliProxyAddress) - unsetEnv(t, envvars.CliCAPath) -} - // TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior. func TestDetectProxyEnv(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) @@ -61,88 +34,11 @@ func TestDetectProxyEnv(t *testing.T) { } } -// TestSharedTransport_DefaultReturnsStdlibSingleton verifies the default shared transport. -func TestSharedTransport_DefaultReturnsStdlibSingleton(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - t.Setenv(EnvNoProxy, "") - tr := SharedTransport() - if tr != http.DefaultTransport { - t.Error("SharedTransport should return http.DefaultTransport when LARK_CLI_NO_PROXY is unset") - } -} - -// TestSharedTransport_NoProxyReturnsClone verifies that disabling proxying returns a cloned transport. -func TestSharedTransport_NoProxyReturnsClone(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - t.Setenv(EnvNoProxy, "1") - tr := SharedTransport() - if tr == http.DefaultTransport { - t.Fatal("SharedTransport should return a clone, not DefaultTransport, when LARK_CLI_NO_PROXY is set") - } - ht, ok := tr.(*http.Transport) - if !ok { - t.Fatalf("expected *http.Transport, got %T", tr) - } - if ht.Proxy != nil { - t.Error("no-proxy transport should have Proxy == nil") - } -} - -// TestSharedTransport_NoProxyIsCachedSingleton verifies singleton caching for the no-proxy transport. -func TestSharedTransport_NoProxyIsCachedSingleton(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - t.Setenv(EnvNoProxy, "1") - a := SharedTransport() - b := SharedTransport() - if a != b { - t.Error("repeated SharedTransport calls with LARK_CLI_NO_PROXY set must return the same instance") - } -} - -// TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault verifies fallback to the stdlib transport after unsetting EnvNoProxy. -func TestSharedTransport_EnvUnsetAfterSetFallsBackToDefault(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - // Simulate a process that first runs with LARK_CLI_NO_PROXY=1 (populating - // the no-proxy singleton), then unsets it. Subsequent calls must return - // http.DefaultTransport, NOT the cached no-proxy clone. - t.Setenv(EnvNoProxy, "1") - noProxy := SharedTransport() - if noProxy == http.DefaultTransport { - t.Fatal("precondition: first call with env set should not return DefaultTransport") - } - - t.Setenv(EnvNoProxy, "") - after := SharedTransport() - if after != http.DefaultTransport { - t.Errorf("after unsetting LARK_CLI_NO_PROXY, SharedTransport must return http.DefaultTransport, got %T (%p)", after, after) - } -} - -// TestSharedTransport_NoProxyOverridesSystemProxy verifies that EnvNoProxy disables system proxies. -func TestSharedTransport_NoProxyOverridesSystemProxy(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - t.Setenv("HTTPS_PROXY", "http://should-be-ignored:8888") - t.Setenv(EnvNoProxy, "1") - - ht, ok := SharedTransport().(*http.Transport) - if !ok { - t.Fatalf("expected *http.Transport, got %T", SharedTransport()) - } - if ht.Proxy != nil { - t.Error("LARK_CLI_NO_PROXY should override system proxy settings") - } -} - // TestWarnIfProxied_WithProxy verifies that proxy detection emits a warning. func TestWarnIfProxied_WithProxy(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) - // Reset the once guard for this test + resetProxyPluginState() proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://corp-proxy:3128") @@ -166,6 +62,7 @@ func TestWarnIfProxied_WithProxy(t *testing.T) { func TestWarnIfProxied_WithoutProxy(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) + resetProxyPluginState() proxyWarningOnce = sync.Once{} for _, k := range proxyEnvKeys { @@ -180,10 +77,11 @@ func TestWarnIfProxied_WithoutProxy(t *testing.T) { } } -// TestWarnIfProxied_SilentWhenDisabled verifies that EnvNoProxy suppresses warnings. +// TestWarnIfProxied_SilentWhenDisabled verifies that LARK_CLI_NO_PROXY suppresses warnings. func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) + resetProxyPluginState() proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://proxy:8080") @@ -201,6 +99,7 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { func TestWarnIfProxied_OnlyOnce(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) + resetProxyPluginState() proxyWarningOnce = sync.Once{} t.Setenv("HTTP_PROXY", "http://proxy:1234") @@ -257,7 +156,7 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { } // TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is -// trusted, the warning surfaces the TLS-interception capability (V3). +// trusted, the warning surfaces the TLS-interception capability. func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) @@ -284,25 +183,6 @@ func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { } } -// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware -// transport (instead of a bare client that bypasses proxy plugin mode). -func TestNewHTTPClient(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - t.Setenv(EnvNoProxy, "") - - c := NewHTTPClient(7 * time.Second) - if c.Transport == nil { - t.Fatal("NewHTTPClient transport is nil; want shared transport") - } - if c.Transport != SharedTransport() { - t.Errorf("NewHTTPClient transport = %v, want SharedTransport()", c.Transport) - } - if c.Timeout != 7*time.Second { - t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout) - } -} - // TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin // warning never leaks credentials embedded in the configured proxy address. func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { @@ -331,8 +211,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { // TestRedactProxyURL verifies redaction of proxy credentials across supported formats. func TestRedactProxyURL(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) tests := []struct { input string want string @@ -359,6 +237,7 @@ func TestRedactProxyURL(t *testing.T) { func TestWarnIfProxied_RedactsCredentials(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) + resetProxyPluginState() proxyWarningOnce = sync.Once{} t.Setenv("HTTPS_PROXY", "http://admin:s3cret@proxy:8080") diff --git a/internal/update/update.go b/internal/update/update.go index c3509b3d..31fef085 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -17,7 +17,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" - "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" ) @@ -64,7 +64,7 @@ func httpClient() *http.Client { } return &http.Client{ Timeout: fetchTimeout, - Transport: util.SharedTransport(), + Transport: transport.Shared(), } } diff --git a/internal/util/proxy.go b/internal/util/proxy.go deleted file mode 100644 index d78134e1..00000000 --- a/internal/util/proxy.go +++ /dev/null @@ -1,180 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package util - -import ( - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "sync" - "time" - - "github.com/larksuite/cli/internal/envvars" - "github.com/larksuite/cli/internal/proxyplugin" -) - -// Proxy environment constants control shared transport proxy behavior. -const ( - // EnvNoProxy disables automatic proxy support when set to any non-empty value. - EnvNoProxy = "LARK_CLI_NO_PROXY" -) - -// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads. -var proxyEnvKeys = []string{ - "HTTPS_PROXY", "https_proxy", - "HTTP_PROXY", "http_proxy", - "ALL_PROXY", "all_proxy", -} - -// DetectProxyEnv returns the first proxy-related environment variable that is set, -// or empty strings if none are configured. -func DetectProxyEnv() (key, value string) { - for _, k := range proxyEnvKeys { - if v := os.Getenv(k); v != "" { - return k, v - } - } - return "", "" -} - -// proxyWarningOnce ensures proxy environment warnings are emitted at most once. -var proxyWarningOnce sync.Once - -// proxyPluginStatus reports the configured proxy plugin address, the extra -// trusted CA path (if any), and whether proxy plugin mode is enabled. It is -// indirected through a package variable so tests can simulate plugin-enabled -// mode without the process-global proxyplugin.Load() sync.Once cache. -var proxyPluginStatus = func() (addr, caPath string, enabled bool) { - cfg, err := proxyplugin.Load() - if err != nil || !cfg.Enabled() { - return "", "", false - } - return cfg.Proxy, cfg.CAPath, true -} - -// redactProxyURL masks userinfo (username:password) in a proxy URL. -// Handles both scheme-prefixed ("http://user:pass@host") and bare ("user:pass@host") formats. -func redactProxyURL(raw string) string { - // Try standard url.Parse first (works when scheme is present) - u, err := url.Parse(raw) - if err == nil && u.User != nil { - return u.Scheme + "://***@" + u.Host + u.RequestURI() - } - - // Fallback: handle bare URLs without scheme (e.g. "user:pass@proxy:8080") - if at := strings.LastIndex(raw, "@"); at > 0 { - return "***@" + raw[at+1:] - } - - return raw -} - -// WarnIfProxied prints a one-time warning to w when a proxy environment variable -// is detected and proxy is not disabled via LARK_CLI_NO_PROXY. Proxy credentials -// are redacted. Safe to call multiple times; only the first call prints. -func WarnIfProxied(w io.Writer) { - proxyWarningOnce.Do(func() { - // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see - // SharedTransport), so its warning and disable instructions take - // precedence. Emitting the env-proxy warning here would be misleading: - // it tells the user to set LARK_CLI_NO_PROXY=1, which does NOT disable - // the plugin proxy. - if addr, caPath, enabled := proxyPluginStatus(); enabled { - fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n", - redactProxyURL(addr), envvars.CliProxyEnable, proxyplugin.Path()) - if strings.TrimSpace(caPath) != "" { - // A custom CA means upstream TLS can be intercepted/inspected by - // the proxy (MITM). Surface it so the operator is aware traffic - // (including Bearer tokens) is decryptable on this host. - fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n", - caPath) - } - return - } - if os.Getenv(EnvNoProxy) != "" { - return - } - key, val := DetectProxyEnv() - if key == "" { - return - } - fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n", - key, redactProxyURL(val), EnvNoProxy) - }) -} - -// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, -// lazily built the first time LARK_CLI_NO_PROXY is observed set. -var noProxyTransport = sync.OnceValue(func() *http.Transport { - def, ok := http.DefaultTransport.(*http.Transport) - if !ok { - return &http.Transport{} - } - t := def.Clone() - t.Proxy = nil - return t -}) - -// SharedTransport returns the base http.RoundTripper for CLI HTTP clients. -// -// By default it returns http.DefaultTransport — the stdlib-provided -// process-wide singleton — so every HTTP client in the process shares one -// TCP connection pool, TLS session cache, and HTTP/2 state. When -// LARK_CLI_NO_PROXY is set it returns a separate proxy-disabled singleton -// clone; LARK_CLI_NO_PROXY is checked on every call, but the clone is built -// at most once. -// -// The returned RoundTripper MUST NOT be mutated. Callers that need a -// customized transport should assert to *http.Transport and Clone() it. -// Using a shared base is required so persistConn readLoop/writeLoop -// goroutines are reused; cloning per call leaks them until IdleConnTimeout -// (~90s) fires. -func SharedTransport() http.RoundTripper { - // proxy plugin mode overrides all other proxy behavior (env proxies and - // LARK_CLI_NO_PROXY), per operator intent. - if t, ok := proxyplugin.SharedTransport(); ok { - return t - } - if os.Getenv(EnvNoProxy) != "" { - return noProxyTransport() - } - return http.DefaultTransport -} - -// FallbackTransport returns a shared *http.Transport singleton. It is a -// thin wrapper over SharedTransport retained so modules that were already -// on the leak-free singleton path (internal/auth, internal/cmdutil -// transport decorators) do not have to migrate. New code should prefer -// SharedTransport and treat the base as an http.RoundTripper. -// -// Fail-closed invariant: proxyplugin always expresses its blocked/fail-closed -// transport as a concrete *http.Transport (see proxyplugin.failClosedTransport), -// so the assertion below preserves the block. The noProxyTransport() fallback is -// therefore only reached when no proxy plugin is configured and some external -// code replaced http.DefaultTransport with a non-*http.Transport — a case with -// no fail-closed intent, where a proxy-disabled transport is acceptable. -func FallbackTransport() *http.Transport { - if t, ok := SharedTransport().(*http.Transport); ok { - return t - } - return noProxyTransport() -} - -// NewHTTPClient returns an *http.Client whose Transport is the shared, -// proxy-plugin-aware base (see SharedTransport). Prefer this over a bare -// &http.Client{} for outbound requests: a bare client falls back to -// http.DefaultTransport and therefore silently bypasses proxy plugin mode -// (fixed proxy + trusted CA, or fail-closed), creating an audit blind spot. -// -// A zero timeout means no client-level timeout (callers relying on -// context deadlines pass 0). -func NewHTTPClient(timeout time.Duration) *http.Client { - return &http.Client{ - Transport: SharedTransport(), - Timeout: timeout, - } -}