diff --git a/CLAUDE.md b/CLAUDE.md index 907ba8c..64c4ba8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,7 @@ Services initialize in `cmd/daemon/main.go`: check enabled flag → create → s - Local overrides: `$HOME/.shelltime/config.local.yaml` (merged over the base, gitignored) - Daemon socket: `/tmp/shelltime.sock` (configurable via `socketPath`) - AICodeOtel gRPC port: configurable via `aiCodeOtel.grpcPort` (default: 54027) +- Outbound proxy: `proxy.url` / `proxy.noProxy` (http, https, socks5, socks5h). Applied once at startup via `model.ConfigureProxy`; every outbound HTTP client must use `model.NewHTTPClient` or `model.HTTPTransport()` so the proxy applies ## Commit Rules diff --git a/README.md b/README.md index 092a6f5..37fb385 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ ShellTime stores data under `~/.shelltime/`. - Local overrides: `~/.shelltime/config.local.yaml` - Also supported: `config.yml`, `config.toml`, `config.local.yml`, `config.local.toml` - Generated schema: `~/.shelltime/config-schema.json` +- Proxy: set `proxy.url` (`http`, `https`, `socks5`, `socks5h`) to route all outbound traffic through a proxy. See [Network Proxy](docs/CONFIG.md#network-proxy) Minimal example: diff --git a/cmd/cli/main.go b/cmd/cli/main.go index f090c3c..f51586e 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -43,6 +43,11 @@ func main() { } cfg, err := configService.ReadConfigFile(ctx) + if err == nil { + if proxyErr := model.ConfigureProxy(cfg.Proxy); proxyErr != nil { + slog.Warn("invalid proxy config, falling back to environment proxy", slog.Any("err", proxyErr)) + } + } if err != nil || cfg.EnableMetrics == nil || *cfg.EnableMetrics == false || diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index 63429fd..b262fc3 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -52,6 +52,12 @@ func main() { slog.DebugContext(ctx, "daemon.config", slog.Any("config", cfg)) + if err := model.ConfigureProxy(cfg.Proxy); err != nil { + slog.Warn("invalid proxy config, falling back to environment proxy", slog.Any("err", err)) + } else if cfg.Proxy != nil && cfg.Proxy.URL != "" { + slog.Info("proxy enabled", slog.String("url", model.RedactProxyURL(cfg.Proxy.URL))) + } + uptraceOptions := []uptrace.Option{ uptrace.WithDSN(uptraceDsn), uptrace.WithServiceName("cli-daemon"), diff --git a/commands/config_view.go b/commands/config_view.go index 7fadaef..ddd944f 100644 --- a/commands/config_view.go +++ b/commands/config_view.go @@ -156,6 +156,8 @@ func flattenConfig(v interface{}, prefix string) []keyValuePair { } else { value = "****" } + } else if fullKey == "proxy.url" { + value = model.RedactProxyURL(value) } pairs = append(pairs, keyValuePair{key: fullKey, value: value}) default: diff --git a/commands/config_view_test.go b/commands/config_view_test.go index 54012e3..07b8626 100644 --- a/commands/config_view_test.go +++ b/commands/config_view_test.go @@ -161,6 +161,17 @@ func TestFlattenConfig_NestedTokenMasking(t *testing.T) { assert.Contains(t, v, "supersecrettoken") } +func TestFlattenConfig_ProxyURLPasswordRedacted(t *testing.T) { + cfg := model.ShellTimeConfig{ + Proxy: &model.ProxyConfig{URL: "socks5h://alice:hunter2@127.0.0.1:1080"}, + } + pairs := flattenConfig(cfg, "") + v, ok := findPair(pairs, "proxy.url") + require.True(t, ok) + assert.NotContains(t, v, "hunter2") + assert.Equal(t, "socks5h://alice:xxxxx@127.0.0.1:1080", v) +} + func TestFlattenConfig_NonStructReturnsEmpty(t *testing.T) { assert.Empty(t, flattenConfig(42, "")) assert.Empty(t, flattenConfig("just a string", "")) diff --git a/daemon/anthropic_ratelimit.go b/daemon/anthropic_ratelimit.go index 73a83b7..2ff307d 100644 --- a/daemon/anthropic_ratelimit.go +++ b/daemon/anthropic_ratelimit.go @@ -13,6 +13,8 @@ import ( "strings" "sync" "time" + + "github.com/malamtime/cli/model" ) const anthropicUsageCacheTTL = 10 * time.Minute @@ -176,7 +178,7 @@ func fetchAnthropicUsage(ctx context.Context, token, version string) (*Anthropic req.Header.Set("User-Agent", "claude-code/"+version) req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: 5 * time.Second} + client := &http.Client{Timeout: 5 * time.Second, Transport: model.HTTPTransport()} resp, err := client.Do(req) if err != nil { return nil, err diff --git a/daemon/codex_ratelimit.go b/daemon/codex_ratelimit.go index 00a6830..558d2a6 100644 --- a/daemon/codex_ratelimit.go +++ b/daemon/codex_ratelimit.go @@ -13,6 +13,8 @@ import ( "strings" "sync" "time" + + "github.com/malamtime/cli/model" ) const codexUsageCacheTTL = 10 * time.Minute @@ -211,7 +213,7 @@ type whamRateLimitWindow struct { // fetchCodexUsage calls the Codex usage API and returns rate limit data. func fetchCodexUsage(ctx context.Context, auth *codexAuthData) (*CodexRateLimitData, error) { - client := &http.Client{Timeout: 5 * time.Second} + client := &http.Client{Timeout: 5 * time.Second, Transport: model.HTTPTransport()} return fetchCodexUsageFromEndpoint(ctx, auth, codexUsageEndpoint, client) } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index cbe2ee5..aa433d4 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -12,6 +12,7 @@ This guide covers every configuration option in ShellTime CLI. ShellTime runs fi - [AI Features](#ai-features) - [Claude Code Integration](#claude-code-integration) - [Codex Usage Tracking](#codex-usage-tracking) +- [Network Proxy](#network-proxy) - [Advanced Settings](#advanced-settings) - [Complete Example](#complete-example) - [FAQ](#faq) @@ -320,6 +321,47 @@ When `apiEndpoint` or `token` is set under `codeTracking`, heartbeats use those --- +## Network Proxy + +Send all outbound HTTP(S) traffic from the CLI and the daemon through a proxy. This covers syncing to shelltime.xyz, the AI command suggestions, `shelltime update`, and the Claude Code / Codex quota lookups. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `proxy.url` | string | - | Proxy URL. Schemes: `http`, `https`, `socks5`, `socks5h` | +| `proxy.noProxy` | string[] | `[]` | Hosts that bypass the proxy (`NO_PROXY` syntax) | + +```yaml +proxy: + url: "socks5h://127.0.0.1:7890" + noProxy: + - "localhost" + - ".corp.example.com" # domain and all subdomains + - "10.0.0.0/8" # CIDR ranges +``` + +**Supported proxy URLs:** + +| Scheme | Example | Notes | +|--------|---------|-------| +| `http` | `http://127.0.0.1:8080` | Plain HTTP proxy. HTTPS requests are tunneled with `CONNECT` | +| `https` | `https://proxy.corp.com:443` | TLS connection to the proxy itself | +| `socks5` | `socks5://127.0.0.1:1080` | SOCKS5. Hostnames are resolved by the proxy | +| `socks5h` | `socks5h://127.0.0.1:1080` | Same as `socks5` | +| _(none)_ | `127.0.0.1:7890` | Treated as `http://127.0.0.1:7890` | + +Credentials can be embedded in the URL, e.g. `http://user:pass@proxy:8080` or `socks5://user:pass@127.0.0.1:1080`. `shelltime config view` masks the password. + +**Notes:** +- When `proxy` is not set, the standard `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` environment variables are used. When it is set, it takes precedence over them. +- Requests to `localhost` and loopback addresses never go through the proxy. +- SOCKS4 is not supported. +- An invalid proxy URL is logged as a warning, and the environment-variable proxy is used instead. +- The daemon reads the proxy on startup, so restart it after changing this setting. +- The optional OTEL metrics exporter (`enableMetrics`) and the `ccusage` subprocess only honor the environment variables. +- Put a machine-specific proxy in `config.local.yaml` to keep it out of a shared config. + +--- + ## Advanced Settings ### Multiple Endpoints @@ -437,6 +479,11 @@ logCleanup: enabled: true thresholdMB: 100 +# --- Network Proxy --- +# proxy: +# url: "socks5h://127.0.0.1:7890" # http, https, socks5, socks5h +# noProxy: ["localhost", ".corp.example.com"] + # --- Advanced --- socketPath: "/tmp/shelltime.sock" enableMetrics: false diff --git a/go.mod b/go.mod index 7ac0a90..f4b1267 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/trace v1.39.0 go.opentelemetry.io/proto/otlp v1.9.0 + golang.org/x/net v0.48.0 google.golang.org/grpc v1.77.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -79,7 +80,6 @@ require ( go.opentelemetry.io/otel/sdk v1.39.0 // indirect go.opentelemetry.io/otel/sdk/log v0.15.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect - golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/model/ai_service.go b/model/ai_service.go index 51df91a..052c21d 100644 --- a/model/ai_service.go +++ b/model/ai_service.go @@ -51,7 +51,7 @@ func (s *sseAIService) QueryCommandStream( req.Header.Set("Accept", "text/event-stream") req.Header.Set("Authorization", "CLI "+endpoint.Token) - client := &http.Client{Timeout: 2 * time.Minute} + client := &http.Client{Timeout: 2 * time.Minute, Transport: HTTPTransport()} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) diff --git a/model/api.base.go b/model/api.base.go index e204006..c7a5208 100644 --- a/model/api.base.go +++ b/model/api.base.go @@ -10,8 +10,6 @@ import ( "log/slog" "net/http" "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // HTTPRequestOptions contains all options for sending an HTTP request @@ -42,10 +40,7 @@ func SendHTTPRequestJSON[T any, R any](opts HTTPRequestOptions[T, R]) error { timeout = opts.Timeout } - client := &http.Client{ - Timeout: timeout, - Transport: otelhttp.NewTransport(http.DefaultTransport), - } + client := NewHTTPClient(timeout) req, err := http.NewRequestWithContext(ctx, opts.Method, opts.Endpoint.APIEndpoint+opts.Path, bytes.NewBuffer(jsonData)) if err != nil { diff --git a/model/config.go b/model/config.go index fb90d57..44e78c2 100644 --- a/model/config.go +++ b/model/config.go @@ -171,6 +171,9 @@ func mergeConfig(base, local *ShellTimeConfig) { if local.CodeTracking != nil { base.CodeTracking = local.CodeTracking } + if local.Proxy != nil { + base.Proxy = local.Proxy + } if local.LogCleanup != nil { base.LogCleanup = local.LogCleanup } diff --git a/model/config_cov_test.go b/model/config_cov_test.go index ae53d7e..8315735 100644 --- a/model/config_cov_test.go +++ b/model/config_cov_test.go @@ -40,6 +40,7 @@ func TestMergeConfig_AllOverrides(t *testing.T) { LogCleanup: &LogCleanup{Enabled: &truthy, ThresholdMB: 42}, SocketPath: "/tmp/local.sock", CodeTracking: &CodeTracking{Token: "ct"}, + Proxy: &ProxyConfig{URL: "socks5://127.0.0.1:1080"}, } mergeConfig(base, local) @@ -61,6 +62,8 @@ func TestMergeConfig_AllOverrides(t *testing.T) { assert.EqualValues(t, 42, base.LogCleanup.ThresholdMB) assert.Equal(t, "/tmp/local.sock", base.SocketPath) require.NotNil(t, base.CodeTracking) + require.NotNil(t, base.Proxy) + assert.Equal(t, "socks5://127.0.0.1:1080", base.Proxy.URL) } // TestMergeConfig_CCOtelMigration covers the deprecated CCOtel -> AICodeOtel diff --git a/model/config_test.go b/model/config_test.go index 7bfc792..b08a93d 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -578,3 +578,50 @@ token = 'local-heartbeat-token'` assert.Equal(t, "https://api.local-heartbeat.com", config.CodeTracking.APIEndpoint) assert.Equal(t, "local-heartbeat-token", config.CodeTracking.Token) } + +func TestReadConfig_Proxy(t *testing.T) { + t.Run("yaml with local override", func(t *testing.T) { + tmpDir := t.TempDir() + yamlConfig := `token: yaml-token +proxy: + url: http://proxy.base:8080 + noProxy: + - localhost + - .corp.example.com` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte(yamlConfig), 0644)) + localConfig := `proxy: + url: socks5h://user:pass@127.0.0.1:1080` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.local.yaml"), []byte(localConfig), 0644)) + + config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background()) + require.NoError(t, err) + require.NotNil(t, config.Proxy) + assert.Equal(t, "socks5h://user:pass@127.0.0.1:1080", config.Proxy.URL) + assert.Empty(t, config.Proxy.NoProxy, "local proxy block replaces the base block") + }) + + t.Run("toml", func(t *testing.T) { + tmpDir := t.TempDir() + tomlConfig := `Token = 'toml-token' + +[proxy] +url = 'socks5://127.0.0.1:1080' +noProxy = ['localhost', '10.0.0.0/8']` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.toml"), []byte(tomlConfig), 0644)) + + config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background()) + require.NoError(t, err) + require.NotNil(t, config.Proxy) + assert.Equal(t, "socks5://127.0.0.1:1080", config.Proxy.URL) + assert.Equal(t, []string{"localhost", "10.0.0.0/8"}, config.Proxy.NoProxy) + }) + + t.Run("unset", func(t *testing.T) { + tmpDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), []byte("token: t"), 0644)) + + config, err := NewConfigService(tmpDir).ReadConfigFile(context.Background()) + require.NoError(t, err) + assert.Nil(t, config.Proxy) + }) +} diff --git a/model/handshake.go b/model/handshake.go index 38f0cba..17936d5 100644 --- a/model/handshake.go +++ b/model/handshake.go @@ -11,8 +11,6 @@ import ( "net/http" "os" "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) type handshakeResponse struct { @@ -39,10 +37,7 @@ func NewHandshakeService(config ShellTimeConfig) HandshakeService { } func (hs handshakeService) send(ctx context.Context, path string, jsonData []byte) (result handshakeResponse, errResp errorResponse, err error) { - hc := http.Client{ - Timeout: time.Second * 30, - Transport: otelhttp.NewTransport(http.DefaultTransport), - } + hc := NewHTTPClient(time.Second * 30) req, err := http.NewRequestWithContext(ctx, "POST", hs.config.APIEndpoint+"/api/v1/handshake"+path, bytes.NewBuffer(jsonData)) if err != nil { diff --git a/model/http_client.go b/model/http_client.go new file mode 100644 index 0000000..eceb6ce --- /dev/null +++ b/model/http_client.go @@ -0,0 +1,103 @@ +package model + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "golang.org/x/net/http/httpproxy" +) + +// supportedProxySchemes lists the proxy schemes natively handled by net/http. +// net/http treats socks5 the same as socks5h: hostnames are resolved by the proxy. +var supportedProxySchemes = map[string]bool{ + "http": true, + "https": true, + "socks5": true, + "socks5h": true, +} + +// sharedTransport is the base transport used by every outbound HTTP client. +// It defaults to a clone of http.DefaultTransport, which honors the +// HTTP_PROXY / HTTPS_PROXY / NO_PROXY environment variables. +var sharedTransport atomic.Pointer[http.Transport] + +func init() { + sharedTransport.Store(http.DefaultTransport.(*http.Transport).Clone()) +} + +// ConfigureProxy routes all outbound HTTP traffic through the configured proxy. +// A nil config or an empty URL keeps the environment-based proxy behavior. +func ConfigureProxy(cfg *ProxyConfig) error { + if cfg == nil || strings.TrimSpace(cfg.URL) == "" { + return nil + } + + proxyURL, err := ParseProxyURL(cfg.URL) + if err != nil { + return err + } + + proxyFunc := (&httpproxy.Config{ + HTTPProxy: proxyURL.String(), + HTTPSProxy: proxyURL.String(), + NoProxy: strings.Join(cfg.NoProxy, ","), + }).ProxyFunc() + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = func(r *http.Request) (*url.URL, error) { + return proxyFunc(r.URL) + } + sharedTransport.Store(transport) + return nil +} + +// ParseProxyURL validates a proxy URL. A bare "host:port" is treated as http. +func ParseProxyURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + + u, err := url.Parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid proxy url: %w", err) + } + + scheme := strings.ToLower(u.Scheme) + if !supportedProxySchemes[scheme] { + return nil, fmt.Errorf("unsupported proxy scheme %q: use http, https, socks5 or socks5h", u.Scheme) + } + u.Scheme = scheme + + if u.Hostname() == "" { + return nil, fmt.Errorf("invalid proxy url %q: missing host", RedactProxyURL(raw)) + } + return u, nil +} + +// RedactProxyURL hides the password of a proxy URL for display and logging. +func RedactProxyURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.User == nil { + return raw + } + return u.Redacted() +} + +// HTTPTransport returns the shared, proxy-aware base transport. +func HTTPTransport() http.RoundTripper { + return sharedTransport.Load() +} + +// NewHTTPClient creates an OTEL-instrumented HTTP client on the shared transport. +func NewHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + Transport: otelhttp.NewTransport(HTTPTransport()), + } +} diff --git a/model/http_client_test.go b/model/http_client_test.go new file mode 100644 index 0000000..21dbf93 --- /dev/null +++ b/model/http_client_test.go @@ -0,0 +1,243 @@ +package model + +import ( + "encoding/binary" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resetSharedTransport restores the default env-based transport after a test. +func resetSharedTransport(t *testing.T) { + t.Cleanup(func() { + sharedTransport.Store(http.DefaultTransport.(*http.Transport).Clone()) + }) +} + +func proxyFor(t *testing.T, target string) *url.URL { + t.Helper() + req, err := http.NewRequest(http.MethodGet, target, nil) + require.NoError(t, err) + u, err := sharedTransport.Load().Proxy(req) + require.NoError(t, err) + return u +} + +func TestParseProxyURL(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr string + }{ + {name: "http", raw: "http://127.0.0.1:8080", want: "http://127.0.0.1:8080"}, + {name: "https", raw: "https://proxy.corp:443", want: "https://proxy.corp:443"}, + {name: "socks5", raw: "socks5://127.0.0.1:1080", want: "socks5://127.0.0.1:1080"}, + {name: "socks5h with auth", raw: "socks5h://u:p@127.0.0.1:1080", want: "socks5h://u:p@127.0.0.1:1080"}, + {name: "uppercase scheme", raw: "SOCKS5://127.0.0.1:1080", want: "socks5://127.0.0.1:1080"}, + {name: "bare host port", raw: "127.0.0.1:7890", want: "http://127.0.0.1:7890"}, + {name: "surrounding spaces", raw: " http://p:1 ", want: "http://p:1"}, + {name: "socks4 rejected", raw: "socks4://127.0.0.1:1080", wantErr: "unsupported proxy scheme"}, + {name: "ftp rejected", raw: "ftp://127.0.0.1:21", wantErr: "unsupported proxy scheme"}, + {name: "missing host", raw: "http://:8080", wantErr: "missing host"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := ParseProxyURL(tt.raw) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, u.String()) + }) + } +} + +func TestRedactProxyURL(t *testing.T) { + assert.Equal(t, "socks5://u:xxxxx@h:1", RedactProxyURL("socks5://u:secret@h:1")) + assert.Equal(t, "http://h:1", RedactProxyURL("http://h:1")) + assert.Equal(t, "", RedactProxyURL("")) +} + +func TestConfigureProxy_NilOrEmptyKeepsEnvBehavior(t *testing.T) { + resetSharedTransport(t) + before := sharedTransport.Load() + + require.NoError(t, ConfigureProxy(nil)) + require.NoError(t, ConfigureProxy(&ProxyConfig{URL: " "})) + assert.Same(t, before, sharedTransport.Load()) + assert.NotNil(t, before.Proxy, "default transport should use the environment proxy func") +} + +func TestConfigureProxy_InvalidKeepsTransport(t *testing.T) { + resetSharedTransport(t) + before := sharedTransport.Load() + + err := ConfigureProxy(&ProxyConfig{URL: "socks4://127.0.0.1:1080"}) + require.Error(t, err) + assert.Same(t, before, sharedTransport.Load()) +} + +func TestConfigureProxy_ProxySelection(t *testing.T) { + resetSharedTransport(t) + require.NoError(t, ConfigureProxy(&ProxyConfig{ + URL: "socks5h://127.0.0.1:1080", + NoProxy: []string{".corp.example.com", "10.0.0.0/8"}, + })) + + u := proxyFor(t, "https://api.shelltime.xyz/api/v1/track") + require.NotNil(t, u) + assert.Equal(t, "socks5h://127.0.0.1:1080", u.String()) + + u = proxyFor(t, "http://github.com/") + require.NotNil(t, u, "plain http requests are proxied too") + + assert.Nil(t, proxyFor(t, "https://git.corp.example.com/"), "noProxy domain suffix bypasses proxy") + assert.Nil(t, proxyFor(t, "http://10.1.2.3/"), "noProxy CIDR bypasses proxy") + assert.Nil(t, proxyFor(t, "http://127.0.0.1:9999/"), "loopback always bypasses proxy") + assert.Nil(t, proxyFor(t, "http://localhost:9999/"), "localhost always bypasses proxy") +} + +func TestNewHTTPClient_ThroughHTTPProxy(t *testing.T) { + resetSharedTransport(t) + + var gotRequestURI, gotHost string + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotRequestURI = r.RequestURI + gotHost = r.Host + _, _ = w.Write([]byte("via-proxy")) + })) + defer proxy.Close() + + require.NoError(t, ConfigureProxy(&ProxyConfig{URL: proxy.URL})) + + resp, err := NewHTTPClient(5 * time.Second).Get("http://shelltime.test/api/v1/ping") + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, "via-proxy", string(body)) + assert.Equal(t, "http://shelltime.test/api/v1/ping", gotRequestURI, "proxy should receive an absolute-URI request") + assert.Equal(t, "shelltime.test", gotHost) +} + +func TestHTTPTransport_ThroughSOCKS5Proxy(t *testing.T) { + resetSharedTransport(t) + + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("hello from " + r.Host)) + })) + defer backend.Close() + + socksAddr, requestedHost := startSOCKS5Server(t, backend.Listener.Addr().String()) + require.NoError(t, ConfigureProxy(&ProxyConfig{URL: "socks5h://" + socksAddr})) + + client := &http.Client{Timeout: 5 * time.Second, Transport: HTTPTransport()} + resp, err := client.Get("http://backend.test/") + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, "hello from backend.test", string(body)) + select { + case host := <-requestedHost: + assert.Equal(t, "backend.test:80", host, "hostname should be resolved by the proxy") + case <-time.After(time.Second): + t.Fatal("socks5 proxy was not used") + } +} + +// startSOCKS5Server runs a minimal no-auth SOCKS5 server that forwards every +// CONNECT to target and reports the requested destination. +func startSOCKS5Server(t *testing.T, target string) (string, <-chan string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + + requested := make(chan string, 1) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleSOCKS5Conn(conn, target, requested) + } + }() + return ln.Addr().String(), requested +} + +func handleSOCKS5Conn(conn net.Conn, target string, requested chan<- string) { + defer conn.Close() + + // Greeting: VER, NMETHODS, METHODS... + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + return + } + if _, err := io.ReadFull(conn, make([]byte, header[1])); err != nil { + return + } + if _, err := conn.Write([]byte{0x05, 0x00}); err != nil { + return + } + + // Request: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT + req := make([]byte, 4) + if _, err := io.ReadFull(conn, req); err != nil { + return + } + var host string + switch req[3] { + case 0x01: + ip := make([]byte, 4) + if _, err := io.ReadFull(conn, ip); err != nil { + return + } + host = net.IP(ip).String() + case 0x03: + l := make([]byte, 1) + if _, err := io.ReadFull(conn, l); err != nil { + return + } + name := make([]byte, l[0]) + if _, err := io.ReadFull(conn, name); err != nil { + return + } + host = string(name) + default: + return + } + portBuf := make([]byte, 2) + if _, err := io.ReadFull(conn, portBuf); err != nil { + return + } + select { + case requested <- net.JoinHostPort(host, strconv.Itoa(int(binary.BigEndian.Uint16(portBuf)))): + default: + } + + upstream, err := net.Dial("tcp", target) + if err != nil { + _, _ = conn.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + defer upstream.Close() + if _, err := conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}); err != nil { + return + } + + go func() { _, _ = io.Copy(upstream, conn) }() + _, _ = io.Copy(conn, upstream) +} diff --git a/model/shell.bash.go b/model/shell.bash.go index 3ca7f8d..741f6c2 100644 --- a/model/shell.bash.go +++ b/model/shell.bash.go @@ -21,7 +21,7 @@ func ensureBashPreexec(hooksDir string) error { return nil // already exists } - client := &http.Client{Timeout: 1 * time.Minute} + client := &http.Client{Timeout: 1 * time.Minute, Transport: HTTPTransport()} resp, err := client.Get(bashPreexecURL) if err != nil { return fmt.Errorf("failed to download bash-preexec.sh: %w", err) diff --git a/model/types.go b/model/types.go index 8d6ff66..465fd31 100644 --- a/model/types.go +++ b/model/types.go @@ -101,11 +101,26 @@ type ShellTimeConfig struct { // always-available txt file store is used. Storage *StorageConfig `toml:"storage" yaml:"storage,omitempty" json:"storage,omitempty"` + // Proxy routes all outbound HTTP(S) traffic through a proxy. + // When unset, the HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars are honored. + Proxy *ProxyConfig `toml:"proxy,omitempty" yaml:"proxy,omitempty" json:"proxy,omitempty"` + // SocketPath is the path to the Unix domain socket used for communication // between the CLI and the daemon. SocketPath string `toml:"socketPath" yaml:"socketPath" json:"socketPath"` } +// ProxyConfig configures the proxy used for outbound HTTP(S) requests. +type ProxyConfig struct { + // URL of the proxy. Supported schemes: http, https, socks5, socks5h. + // Credentials may be embedded, e.g. socks5://user:pass@127.0.0.1:1080. + // A bare "host:port" is treated as http://host:port. + URL string `toml:"url" yaml:"url" json:"url"` + // NoProxy lists hosts that bypass the proxy, using NO_PROXY syntax: + // "localhost", ".internal.corp", "10.0.0.0/8", "*.example.com". + NoProxy []string `toml:"noProxy,omitempty" yaml:"noProxy,omitempty" json:"noProxy,omitempty"` +} + // StorageConfig selects which CommandStore backend buffers tracked commands // before they sync to the server. type StorageConfig struct { diff --git a/model/updater.go b/model/updater.go index cd3eab9..c00caa7 100644 --- a/model/updater.go +++ b/model/updater.go @@ -17,8 +17,6 @@ import ( "runtime" "strings" "time" - - "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) const ( @@ -58,10 +56,7 @@ type LatestRelease struct { } func newUpdaterHTTPClient(timeout time.Duration) *http.Client { - return &http.Client{ - Timeout: timeout, - Transport: otelhttp.NewTransport(http.DefaultTransport), - } + return NewHTTPClient(timeout) } func updaterUserAgent() string {