mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 10:08:02 +08:00
Resolve conflicts: - secheader.go: keep both HEAD ppe header and main AgentTrace header - proxy_test.go: union of both test sets; align main's plugin tests to the 2-arg WarnIfProxied(w, interactive) signature - SKILL.md: take feat side (refactored 2.0.0 + others' value-render-option removal) - drop 5 legacy sheets sources + cell-data.md (refactored away; main's #996/#1001/#984/#1073 covered by new code or no longer applicable)
192 lines
7.2 KiB
Go
192 lines
7.2 KiB
Go
// 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.
|
|
//
|
|
// The warning is suppressed entirely when interactive is false — i.e. stdin is
|
|
// not a TTY, which is the case for agent / CI / piped invocations. Those callers
|
|
// frequently parse the CLI's stdout as JSON and merge streams with `2>&1`; a
|
|
// stray stderr warning then corrupts the parsed payload. Suppressing in the
|
|
// non-interactive case keeps machine-consumed output clean, while human
|
|
// interactive sessions still get the security notice. Passing interactive=false
|
|
// does not consume the once guard, so a later interactive call can still warn.
|
|
func WarnIfProxied(w io.Writer, interactive bool) {
|
|
if !interactive {
|
|
return
|
|
}
|
|
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,
|
|
}
|
|
}
|