mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport, NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util package depended up into a feature package, pulling binding/core/vfs into the transitive cone of every util importer. Move internal/proxyplugin -> internal/transport and make it the single owner of outbound transport: fold the two SharedTransport functions into one Shared() (proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses to one. internal/util keeps no proxy code and is a leaf again. Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to internal/transport. Behavior-preserving: package move + symbol rename + dedup. Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed config never falls through to direct egress).
244 lines
7.6 KiB
Go
244 lines
7.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
// 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.
|
|
//
|
|
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/larksuite/cli/internal/binding"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/envvars"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
// ConfigFileName is the fixed config file name under core.GetConfigDir().
|
|
const (
|
|
ConfigFileName = "proxy_config.json"
|
|
)
|
|
|
|
// Config is the on-disk config format. Keys intentionally mirror env var names.
|
|
type Config struct {
|
|
// Enable turns on proxy plugin transport handling.
|
|
Enable bool `json:"LARKSUITE_CLI_PROXY_ENABLE"`
|
|
|
|
// Proxy is the fixed HTTP proxy address used for all outbound requests.
|
|
Proxy string `json:"LARKSUITE_CLI_PROXY_ADDRESS"`
|
|
|
|
// CAPath points to an extra PEM bundle trusted for proxy TLS interception.
|
|
CAPath string `json:"LARKSUITE_CLI_CA_PATH"`
|
|
}
|
|
|
|
// Path returns the absolute path to the proxy plugin config file.
|
|
func Path() string {
|
|
return filepath.Join(core.GetConfigDir(), ConfigFileName)
|
|
}
|
|
|
|
// loadOnce guards one-time proxy config loading for process-wide transport reuse.
|
|
var loadOnce sync.Once
|
|
|
|
// loadCfg stores the cached proxy config after the first successful Load call.
|
|
var loadCfg *Config
|
|
|
|
// loadErr stores the cached Load error observed during the first load attempt.
|
|
var loadErr error
|
|
|
|
// Load reads ~/.lark-cli/proxy_config.json once and caches the parsed result.
|
|
// Environment variables (CliProxyEnable/CliProxyAddress/CliCAPath) take precedence over config file values.
|
|
//
|
|
// Returns (nil, nil) only when:
|
|
// - the config file does not exist AND
|
|
// - none of the proxy-related env vars are present.
|
|
func Load() (*Config, error) {
|
|
loadOnce.Do(func() {
|
|
// Start from env-only config if any proxy env var is present.
|
|
cfg, hasEnv, err := loadFromEnv()
|
|
if err != nil {
|
|
loadErr = err
|
|
return
|
|
}
|
|
|
|
p := Path()
|
|
if _, err := vfs.Stat(p); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
// No file: return env-only config (if any), else nil.
|
|
if hasEnv {
|
|
loadCfg = cfg
|
|
} else {
|
|
loadCfg = nil
|
|
}
|
|
loadErr = nil
|
|
return
|
|
}
|
|
loadErr = fmt.Errorf("failed to stat proxy plugin config %q: %w", p, err)
|
|
return
|
|
}
|
|
// Security hardening: this config dictates where ALL outbound CLI traffic
|
|
// egresses and which extra CA is trusted, so a file another local user or
|
|
// process can tamper with (symlink, foreign owner, group/world-writable)
|
|
// could redirect credential traffic. Audit it the same way the CA file is.
|
|
safePath, err := binding.AssertSecurePath(binding.AuditParams{
|
|
TargetPath: p,
|
|
Label: ConfigFileName,
|
|
AllowReadableByOthers: true, // config is not a secret; only writability/owner/symlink matter
|
|
})
|
|
if err != nil {
|
|
loadErr = fmt.Errorf("unsafe proxy plugin config %q: %w", p, err)
|
|
return
|
|
}
|
|
b, err := vfs.ReadFile(safePath)
|
|
if err != nil {
|
|
loadErr = fmt.Errorf("failed to read proxy plugin config %q: %w", p, err)
|
|
return
|
|
}
|
|
var fileCfg Config
|
|
if err := json.Unmarshal(b, &fileCfg); err != nil {
|
|
loadErr = fmt.Errorf("invalid proxy plugin config %q: %w", p, err)
|
|
return
|
|
}
|
|
|
|
// Merge: file base + env overrides.
|
|
if cfg == nil {
|
|
cfg = &fileCfg
|
|
} else {
|
|
*cfg = fileCfg
|
|
applyEnvOverrides(cfg)
|
|
}
|
|
loadCfg = cfg
|
|
})
|
|
return loadCfg, loadErr
|
|
}
|
|
|
|
// Enabled reports whether proxy plugin mode is enabled.
|
|
func (c *Config) Enabled() bool { return c != nil && c.Enable }
|
|
|
|
// loadFromEnv builds a config from proxy-related environment variables only.
|
|
// It reports whether any proxy-related environment variable was present.
|
|
func loadFromEnv() (*Config, bool, error) {
|
|
_, hasEnable := os.LookupEnv(envvars.CliProxyEnable)
|
|
_, hasProxy := os.LookupEnv(envvars.CliProxyAddress)
|
|
_, hasCA := os.LookupEnv(envvars.CliCAPath)
|
|
hasAny := hasEnable || hasProxy || hasCA
|
|
if !hasAny {
|
|
return nil, false, nil
|
|
}
|
|
cfg := &Config{}
|
|
if err := applyEnvOverrides(cfg); err != nil {
|
|
return nil, true, err
|
|
}
|
|
return cfg, true, nil
|
|
}
|
|
|
|
// applyEnvOverrides copies proxy-related environment variable values into cfg.
|
|
func applyEnvOverrides(cfg *Config) error {
|
|
if v, ok := os.LookupEnv(envvars.CliProxyEnable); ok {
|
|
b, err := parseBoolEnv(envvars.CliProxyEnable, v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Enable = b
|
|
}
|
|
if v, ok := os.LookupEnv(envvars.CliProxyAddress); ok {
|
|
cfg.Proxy = v
|
|
}
|
|
if v, ok := os.LookupEnv(envvars.CliCAPath); ok {
|
|
cfg.CAPath = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parseBoolEnv accepts common boolean spellings used in environment variables.
|
|
func parseBoolEnv(name, raw string) (bool, error) {
|
|
s := strings.TrimSpace(strings.ToLower(raw))
|
|
if s == "" {
|
|
// Treat empty as false when explicitly present.
|
|
return false, nil
|
|
}
|
|
switch s {
|
|
case "1", "true", "on", "yes", "y":
|
|
return true, nil
|
|
case "0", "false", "off", "no", "n":
|
|
return false, nil
|
|
}
|
|
if b, err := strconv.ParseBool(s); err == nil {
|
|
return b, nil
|
|
}
|
|
return false, fmt.Errorf("invalid %s %q (want true/false/1/0)", name, raw)
|
|
}
|
|
|
|
// proxyURL validates the fixed configured proxy configuration and returns its URL.
|
|
func (c *Config) proxyURL() (*url.URL, error) {
|
|
raw := strings.TrimSpace(c.Proxy)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("%s is empty", envvars.CliProxyAddress)
|
|
}
|
|
redacted := redactProxyURL(raw)
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
// Do not wrap the raw url.Parse error: its string embeds the original
|
|
// URL, which can contain userinfo (user:password). Return a redacted,
|
|
// generic message instead.
|
|
return nil, fmt.Errorf("invalid %s %q: malformed URL", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.Scheme != "http" {
|
|
return nil, fmt.Errorf("invalid %s %q: scheme must be http", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.Host == "" {
|
|
return nil, fmt.Errorf("invalid %s %q: missing host", envvars.CliProxyAddress, redacted)
|
|
}
|
|
// Security hardening: only allow a loopback proxy. This prevents accidental
|
|
// cross-machine proxying of credentials/traffic.
|
|
if u.Hostname() != "127.0.0.1" {
|
|
return nil, fmt.Errorf("invalid %s %q: host must be 127.0.0.1", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.Port() == "" {
|
|
return nil, fmt.Errorf("invalid %s %q: explicit port is required", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.Path != "" {
|
|
return nil, fmt.Errorf("invalid %s %q: path is not allowed", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.RawQuery != "" {
|
|
return nil, fmt.Errorf("invalid %s %q: query is not allowed", envvars.CliProxyAddress, redacted)
|
|
}
|
|
if u.Fragment != "" {
|
|
return nil, fmt.Errorf("invalid %s %q: fragment is not allowed", envvars.CliProxyAddress, redacted)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// 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) {
|
|
if base == nil {
|
|
base = http.DefaultTransport.(*http.Transport)
|
|
}
|
|
u, err := c.proxyURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
t := base.Clone()
|
|
t.Proxy = http.ProxyURL(u) // fixed proxy overrides environment proxy vars
|
|
if err := applyExtraRootCA(t, c.CAPath); err != nil {
|
|
return nil, err
|
|
}
|
|
return t, nil
|
|
}
|