From 62c3db88efc10330d0895b3af48850b7f1cf7493 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Tue, 18 Nov 2025 16:09:01 -0800 Subject: [PATCH] Add proxy-aware dialing functions, and convert cmd/... tooling over. --- cmd/cert-revcheck/main.go | 29 +-- cmd/certchain/main.go | 9 +- cmd/certdump/main.go | 5 +- cmd/certexpiry/main.go | 5 +- cmd/certser/main.go | 5 +- cmd/certverify/main.go | 4 +- cmd/rhash/main.go | 10 +- cmd/stealchain/main.go | 10 +- cmd/tlsinfo/main.go | 17 +- go.mod | 3 +- go.sum | 6 + lib/dialer.go | 452 ++++++++++++++++++++++++++++++++++++++ {certlib => lib}/fetch.go | 41 ++-- lib/lib.go | 1 - release-docker.sh | 70 ++++++ 15 files changed, 589 insertions(+), 78 deletions(-) create mode 100644 lib/dialer.go rename {certlib => lib}/fetch.go (81%) create mode 100755 release-docker.sh diff --git a/cmd/cert-revcheck/main.go b/cmd/cert-revcheck/main.go index be467d4..fc3b6d0 100644 --- a/cmd/cert-revcheck/main.go +++ b/cmd/cert-revcheck/main.go @@ -7,7 +7,6 @@ import ( "errors" "flag" "fmt" - "net" "os" "strings" "time" @@ -16,6 +15,7 @@ import ( hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts" "git.wntrmute.dev/kyle/goutils/certlib/revoke" "git.wntrmute.dev/kyle/goutils/fileutil" + "git.wntrmute.dev/kyle/goutils/lib" ) var ( @@ -38,8 +38,10 @@ func main() { flag.Parse() revoke.HardFail = hardfail - // Set HTTP client timeout for revocation library - revoke.HTTPClient.Timeout = timeout + // Build a proxy-aware HTTP client for OCSP/CRL fetches + if httpClient, err := lib.NewHTTPClient(lib.DialerOpts{Timeout: timeout}); err == nil { + revoke.HTTPClient = httpClient + } if flag.NArg() == 0 { fmt.Fprintf(os.Stderr, "Usage: %s [options] [...]\n", os.Args[0]) @@ -99,28 +101,19 @@ func checkSite(hostport string) (string, error) { return strUnknown, err } - d := &net.Dialer{Timeout: timeout} - tcfg := &tls.Config{ - InsecureSkipVerify: true, - ServerName: target.Host, - } // #nosec G402 -- CLI tool only verifies revocation - td := &tls.Dialer{NetDialer: d, Config: tcfg} - ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - conn, err := td.DialContext(ctx, "tcp", target.String()) + // Use proxy-aware TLS dialer + conn, err := lib.DialTLS(ctx, target.String(), lib.DialerOpts{Timeout: timeout, TLSConfig: &tls.Config{ + InsecureSkipVerify: true, // #nosec G402 -- CLI tool only verifies revocation + ServerName: target.Host, + }}) if err != nil { return strUnknown, err } defer conn.Close() - - tconn, ok := conn.(*tls.Conn) - if !ok { - return strUnknown, errors.New("connection is not TLS") - } - - state := tconn.ConnectionState() + state := conn.ConnectionState() if len(state.PeerCertificates) == 0 { return strUnknown, errors.New("no peer certificates presented") } diff --git a/cmd/certchain/main.go b/cmd/certchain/main.go index 0df6460..042a2dc 100644 --- a/cmd/certchain/main.go +++ b/cmd/certchain/main.go @@ -11,6 +11,7 @@ import ( "strings" "git.wntrmute.dev/kyle/goutils/die" + "git.wntrmute.dev/kyle/goutils/lib" ) var hasPort = regexp.MustCompile(`:\d+$`) @@ -23,13 +24,9 @@ func main() { server += ":443" } - d := &tls.Dialer{Config: &tls.Config{}} // #nosec G402 - nc, err := d.DialContext(context.Background(), "tcp", server) + // Use proxy-aware TLS dialer + conn, err := lib.DialTLS(context.Background(), server, lib.DialerOpts{TLSConfig: &tls.Config{}}) // #nosec G402 die.If(err) - conn, ok := nc.(*tls.Conn) - if !ok { - die.With("invalid TLS connection (not a *tls.Conn)") - } defer conn.Close() diff --git a/cmd/certdump/main.go b/cmd/certdump/main.go index c24c5e0..03c4f2a 100644 --- a/cmd/certdump/main.go +++ b/cmd/certdump/main.go @@ -17,7 +17,6 @@ import ( "github.com/kr/text" - "git.wntrmute.dev/kyle/goutils/certlib" "git.wntrmute.dev/kyle/goutils/lib" ) @@ -351,14 +350,14 @@ func main() { flag.BoolVar(&leafOnly, "l", false, "only show the leaf certificate") flag.Parse() - opts := &certlib.FetcherOpts{ + opts := &lib.FetcherOpts{ SkipVerify: true, Roots: nil, } for _, filename := range flag.Args() { fmt.Fprintf(os.Stdout, "--%s ---%s", filename, "\n") - certs, err := certlib.GetCertificateChain(filename, opts) + certs, err := lib.GetCertificateChain(filename, opts) if err != nil { _, _ = lib.Warn(err, "couldn't read certificate") continue diff --git a/cmd/certexpiry/main.go b/cmd/certexpiry/main.go index 71a680d..7529606 100644 --- a/cmd/certexpiry/main.go +++ b/cmd/certexpiry/main.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "git.wntrmute.dev/kyle/goutils/certlib" "git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/lib" ) @@ -75,7 +74,7 @@ func checkCert(cert *x509.Certificate) { } func main() { - opts := &certlib.FetcherOpts{} + opts := &lib.FetcherOpts{} flag.BoolVar(&opts.SkipVerify, "k", false, "skip server verification") flag.BoolVar(&warnOnly, "q", false, "only warn about expiring certs") @@ -83,7 +82,7 @@ func main() { flag.Parse() for _, file := range flag.Args() { - certs, err := certlib.GetCertificateChain(file, opts) + certs, err := lib.GetCertificateChain(file, opts) if err != nil { _, _ = lib.Warn(err, "while parsing certificates") continue diff --git a/cmd/certser/main.go b/cmd/certser/main.go index 83243b8..c7734ab 100644 --- a/cmd/certser/main.go +++ b/cmd/certser/main.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "git.wntrmute.dev/kyle/goutils/certlib" "git.wntrmute.dev/kyle/goutils/die" "git.wntrmute.dev/kyle/goutils/lib" ) @@ -32,7 +31,7 @@ func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string { } func main() { - opts := &certlib.FetcherOpts{} + opts := &lib.FetcherOpts{} displayAs := flag.String("d", "int", "display mode (int, hex, uhex)") showExpiry := flag.Bool("e", false, "show expiry date") flag.BoolVar(&opts.SkipVerify, "k", false, "skip server verification") @@ -41,7 +40,7 @@ func main() { displayMode := parseDisplayMode(*displayAs) for _, arg := range flag.Args() { - cert, err := certlib.GetCertificate(arg, opts) + cert, err := lib.GetCertificate(arg, opts) die.If(err) fmt.Printf("%s: %s", arg, serialString(cert, displayMode)) diff --git a/cmd/certverify/main.go b/cmd/certverify/main.go index 95c07ec..a997645 100644 --- a/cmd/certverify/main.go +++ b/cmd/certverify/main.go @@ -108,12 +108,12 @@ func run(cfg appConfig) error { return fmt.Errorf("failed to build combined pool: %w", err) } - opts := &certlib.FetcherOpts{ + opts := &lib.FetcherOpts{ Roots: combinedPool, SkipVerify: cfg.skipVerify, } - chain, err := certlib.GetCertificateChain(flag.Arg(0), opts) + chain, err := lib.GetCertificateChain(flag.Arg(0), opts) if err != nil { return err } diff --git a/cmd/rhash/main.go b/cmd/rhash/main.go index ddb479d..67d909a 100644 --- a/cmd/rhash/main.go +++ b/cmd/rhash/main.go @@ -9,6 +9,7 @@ import ( "net/url" "os" "path/filepath" + "time" "git.wntrmute.dev/kyle/goutils/ahash" "git.wntrmute.dev/kyle/goutils/die" @@ -82,8 +83,13 @@ func main() { _, _ = lib.Warn(reqErr, "building request for %s", remote) continue } - client := &http.Client{} - resp, err := client.Do(req) + // Use proxy-aware HTTP client with a reasonable timeout for connects/handshakes + httpClient, err := lib.NewHTTPClient(lib.DialerOpts{Timeout: 30 * time.Second}) + if err != nil { + _, _ = lib.Warn(err, "building HTTP client for %s", remote) + continue + } + resp, err := httpClient.Do(req) if err != nil { _, _ = lib.Warn(err, "fetching %s", remote) continue diff --git a/cmd/stealchain/main.go b/cmd/stealchain/main.go index b1f6d0a..a9c960a 100644 --- a/cmd/stealchain/main.go +++ b/cmd/stealchain/main.go @@ -11,6 +11,7 @@ import ( "os" "git.wntrmute.dev/kyle/goutils/die" + "git.wntrmute.dev/kyle/goutils/lib" ) func main() { @@ -44,15 +45,10 @@ func main() { if err != nil { site += ":443" } - d := &tls.Dialer{Config: cfg} - nc, err := d.DialContext(context.Background(), "tcp", site) + // Use proxy-aware TLS dialer + conn, err := lib.DialTLS(context.Background(), site, lib.DialerOpts{TLSConfig: cfg}) die.If(err) - conn, ok := nc.(*tls.Conn) - if !ok { - die.With("invalid TLS connection (not a *tls.Conn)") - } - cs := conn.ConnectionState() var chain []byte diff --git a/cmd/tlsinfo/main.go b/cmd/tlsinfo/main.go index 6b0efe5..f26c9fe 100644 --- a/cmd/tlsinfo/main.go +++ b/cmd/tlsinfo/main.go @@ -9,6 +9,7 @@ import ( "git.wntrmute.dev/kyle/goutils/certlib/hosts" "git.wntrmute.dev/kyle/goutils/die" + "git.wntrmute.dev/kyle/goutils/lib" ) func main() { @@ -20,18 +21,14 @@ func main() { hostPort, err := hosts.ParseHost(os.Args[1]) die.If(err) - d := &tls.Dialer{Config: &tls.Config{ - InsecureSkipVerify: true, - }} // #nosec G402 - - nc, err := d.DialContext(context.Background(), "tcp", hostPort.String()) + // Use proxy-aware TLS dialer; skip verification as before + conn, err := lib.DialTLS( + context.Background(), + hostPort.String(), + lib.DialerOpts{TLSConfig: &tls.Config{InsecureSkipVerify: true}}, + ) // #nosec G402 die.If(err) - conn, ok := nc.(*tls.Conn) - if !ok { - die.With("invalid TLS connection (not a *tls.Conn)") - } - defer conn.Close() state := conn.ConnectionState() diff --git a/go.mod b/go.mod index 4d32198..00f4fdd 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,8 @@ require ( github.com/hashicorp/go-syslog v1.0.0 github.com/kr/text v0.2.0 github.com/pkg/sftp v1.12.0 - golang.org/x/crypto v0.44.0 + golang.org/x/crypto v0.39.0 + golang.org/x/net v0.38.0 golang.org/x/sys v0.38.0 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index dc79436..64902e2 100644 --- a/go.sum +++ b/go.sum @@ -27,13 +27,19 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/lib/dialer.go b/lib/dialer.go new file mode 100644 index 0000000..2f09315 --- /dev/null +++ b/lib/dialer.go @@ -0,0 +1,452 @@ +// Package lib contains reusable helpers. This file provides proxy-aware +// dialers for plain TCP and TLS connections using environment variables. +// +// Supported proxy environment variables (checked case-insensitively): +// - SOCKS5_PROXY (e.g., socks5://user:pass@host:1080) +// - HTTPS_PROXY (e.g., https://user:pass@host:443) +// - HTTP_PROXY (e.g., http://user:pass@host:3128) +// +// Precedence when multiple proxies are set (both for net and TLS dialers): +// 1. SOCKS5_PROXY +// 2. HTTPS_PROXY +// 3. HTTP_PROXY +// +// Both uppercase and lowercase variable names are honored. +package lib + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + xproxy "golang.org/x/net/proxy" +) + +// DialerOpts controls creation of proxy-aware dialers. +// +// Timeout controls the maximum amount of time spent establishing the +// underlying TCP connection and any proxy handshake. If zero, a +// reasonable default (30s) is used. +// +// TLSConfig is used by the TLS dialer to configure the TLS handshake to +// the target endpoint. If TLSConfig.ServerName is empty, it will be set +// from the host portion of the address passed to DialContext. +type DialerOpts struct { + Timeout time.Duration + TLSConfig *tls.Config +} + +// ContextDialer matches the common DialContext signature used by net and tls dialers. +type ContextDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// DialTCP is a convenience helper that dials a TCP connection to address +// using a proxy-aware dialer derived from opts. It honors SOCKS5_PROXY, +// HTTPS_PROXY, and HTTP_PROXY environment variables. +func DialTCP(ctx context.Context, address string, opts DialerOpts) (net.Conn, error) { + d, err := NewNetDialer(opts) + if err != nil { + return nil, err + } + return d.DialContext(ctx, "tcp", address) +} + +// DialTLS is a convenience helper that dials a TLS-wrapped TCP connection to +// address using a proxy-aware dialer derived from opts. It returns a *tls.Conn. +// It honors SOCKS5_PROXY, HTTPS_PROXY, and HTTP_PROXY environment variables and +// uses opts.TLSConfig for the handshake (filling ServerName from address if empty). +func DialTLS(ctx context.Context, address string, opts DialerOpts) (*tls.Conn, error) { + d, err := NewTLSDialer(opts) + if err != nil { + return nil, err + } + + c, err := d.DialContext(ctx, "tcp", address) + if err != nil { + return nil, err + } + + tlsConn, ok := c.(*tls.Conn) + if !ok { + _ = c.Close() + return nil, fmt.Errorf("DialTLS: expected *tls.Conn, got %T", c) + } + return tlsConn, nil +} + +// NewNetDialer returns a ContextDialer that dials TCP connections using +// proxies discovered from the environment (SOCKS5_PROXY, HTTPS_PROXY, HTTP_PROXY). +// The returned dialer supports context cancellation for direct and HTTP(S) +// proxies and applies the configured timeout to connection/proxy handshake. +func NewNetDialer(opts DialerOpts) (ContextDialer, error) { + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Second + } + + if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil { + return newSOCKS5Dialer(u, opts) + } + + if u := getProxyURLFromEnv("HTTPS_PROXY"); u != nil { + return &httpProxyDialer{ + proxyURL: u, + timeout: opts.Timeout, + secure: true, + config: opts.TLSConfig, + }, nil + } + + if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil { + return &httpProxyDialer{ + proxyURL: u, + timeout: opts.Timeout, + secure: true, + config: opts.TLSConfig, + }, nil + } + + // Direct dialer + return &net.Dialer{Timeout: opts.Timeout}, nil +} + +// NewTLSDialer returns a ContextDialer that establishes a TLS connection to +// the destination, while honoring SOCKS5_PROXY/HTTPS_PROXY/HTTP_PROXY. +// +// The returned dialer performs proxy negotiation (if any), then completes a +// TLS handshake to the target using opts.TLSConfig. +func NewTLSDialer(opts DialerOpts) (ContextDialer, error) { + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Second + } + + // Prefer SOCKS5 if present. + if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil { + base, err := newSOCKS5Dialer(u, opts) + if err != nil { + return nil, err + } + return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil + } + + // For TLS, prefer HTTPS proxy over HTTP if both set. + if u := getProxyURLFromEnv("HTTPS_PROXY"); u != nil { + base := &httpProxyDialer{ + proxyURL: u, + timeout: opts.Timeout, + secure: true, + config: opts.TLSConfig, + } + return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil + } + if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil { + base := &httpProxyDialer{ + proxyURL: u, + timeout: opts.Timeout, + secure: true, + config: opts.TLSConfig, + } + return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil + } + + // Direct TLS + base := &net.Dialer{Timeout: opts.Timeout} + return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil +} + +// ---- Implementation helpers ---- + +func getProxyURLFromEnv(name string) *url.URL { + // check both upper/lowercase + v := os.Getenv(name) + if v == "" { + v = os.Getenv(strings.ToLower(name)) + } + if v == "" { + return nil + } + // If scheme omitted, infer from env var name. + if !strings.Contains(v, "://") { + switch strings.ToUpper(name) { + case "SOCKS5_PROXY": + v = "socks5://" + v + case "HTTPS_PROXY": + v = "https://" + v + default: + v = "http://" + v + } + } + u, err := url.Parse(v) + if err != nil { + return nil + } + return u +} + +// NewHTTPClient returns an *http.Client that is proxy-aware. +// +// Behavior: +// - If SOCKS5_PROXY is set, the client routes all TCP connections through the +// SOCKS5 proxy using a custom DialContext, and disables HTTP(S) proxying in +// the transport (per our precedence SOCKS5 > HTTPS > HTTP). +// - Otherwise, it uses http.ProxyFromEnvironment which supports HTTP_PROXY, +// HTTPS_PROXY, and NO_PROXY/no_proxy. +// - Connection and TLS handshake timeouts are derived from opts.Timeout. +// - For HTTPS targets, opts.TLSConfig is applied to the transport. +func NewHTTPClient(opts DialerOpts) (*http.Client, error) { + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Second + } + + // Base transport configuration + tr := &http.Transport{ + TLSClientConfig: opts.TLSConfig, + TLSHandshakeTimeout: opts.Timeout, + // Leave other fields as Go defaults for compatibility. + } + + // If SOCKS5 is configured, use our dialer and disable HTTP proxying to + // avoid double-proxying. Otherwise, rely on ProxyFromEnvironment for + // HTTP(S) proxies and still set a connect timeout via net.Dialer. + if u := getProxyURLFromEnv("SOCKS5_PROXY"); u != nil { + d, err := newSOCKS5Dialer(u, opts) + if err != nil { + return nil, err + } + tr.Proxy = nil + tr.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + return d.DialContext(ctx, network, address) + } + } else { + tr.Proxy = http.ProxyFromEnvironment + // Use a standard net.Dialer to ensure we apply a connect timeout. + nd := &net.Dialer{Timeout: opts.Timeout} + tr.DialContext = nd.DialContext + } + + // Construct client; we don't set Client.Timeout here to avoid affecting + // streaming responses. Callers can set it if they want an overall deadline. + return &http.Client{Transport: tr}, nil +} + +// httpProxyDialer implements CONNECT tunneling over HTTP or HTTPS proxy. +type httpProxyDialer struct { + proxyURL *url.URL + timeout time.Duration + secure bool // true for HTTPS proxy + config *tls.Config +} + +func (d *httpProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if !strings.HasPrefix(network, "tcp") { + return nil, fmt.Errorf("http proxy dialer only supports TCP, got %q", network) + } + + // Dial to proxy + var nd = &net.Dialer{Timeout: d.timeout} + proxyAddr := d.proxyURL.Host + if !strings.Contains(proxyAddr, ":") { + if d.secure { + proxyAddr += ":443" + } else { + proxyAddr += ":80" + } + } + conn, err := nd.DialContext(ctx, "tcp", proxyAddr) + if err != nil { + return nil, err + } + + // Deadline covering CONNECT and (for TLS wrapper) will be handled by caller too. + if d.timeout > 0 { + _ = conn.SetDeadline(time.Now().Add(d.timeout)) + } + + // If HTTPS proxy, wrap with TLS to the proxy itself. + if d.secure { + host := d.proxyURL.Hostname() + d.config.ServerName = host + tlsConn := tls.Client(conn, d.config) + if err = tlsConn.HandshakeContext(ctx); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("tls handshake with https proxy failed: %w", err) + } + conn = tlsConn + } + + req := buildConnectRequest(d.proxyURL, address) + if _, err = conn.Write([]byte(req)); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed to write CONNECT request: %w", err) + } + + // Read proxy response until end of headers + br := bufio.NewReader(conn) + statusLine, err := br.ReadString('\n') + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("failed to read CONNECT response: %w", err) + } + + if !strings.HasPrefix(statusLine, "HTTP/") { + _ = conn.Close() + return nil, fmt.Errorf("invalid proxy response: %q", strings.TrimSpace(statusLine)) + } + + if !strings.Contains(statusLine, " 200 ") && !strings.HasSuffix(strings.TrimSpace(statusLine), " 200") { + // Drain headers for context + _ = drainHeaders(br) + _ = conn.Close() + return nil, fmt.Errorf("proxy CONNECT failed: %s", strings.TrimSpace(statusLine)) + } + + if err = drainHeaders(br); err != nil { + _ = conn.Close() + return nil, err + } + + // Clear deadline for caller to manage further I/O. + _ = conn.SetDeadline(time.Time{}) + return conn, nil +} + +func buildConnectRequest(proxyURL *url.URL, target string) string { + var b strings.Builder + fmt.Fprintf(&b, "CONNECT %s HTTP/1.1\r\n", target) + fmt.Fprintf(&b, "Host: %s\r\n", target) + b.WriteString("Proxy-Connection: Keep-Alive\r\n") + b.WriteString("User-Agent: goutils-dialer/1\r\n") + + if proxyURL.User != nil { + user := proxyURL.User.Username() + pass, _ := proxyURL.User.Password() + auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) + fmt.Fprintf(&b, "Proxy-Authorization: Basic %s\r\n", auth) + } + b.WriteString("\r\n") + return b.String() +} + +func drainHeaders(br *bufio.Reader) error { + for { + line, err := br.ReadString('\n') + if err != nil { + return fmt.Errorf("reading proxy headers: %w", err) + } + if line == "\r\n" || line == "\n" { + return nil + } + } +} + +// newSOCKS5Dialer builds a context-aware wrapper over the x/net/proxy dialer. +func newSOCKS5Dialer(u *url.URL, opts DialerOpts) (ContextDialer, error) { + var auth *xproxy.Auth + if u.User != nil { + user := u.User.Username() + pass, _ := u.User.Password() + auth = &xproxy.Auth{User: user, Password: pass} + } + forward := &net.Dialer{Timeout: opts.Timeout} + d, err := xproxy.SOCKS5("tcp", hostPortWithDefault(u, "1080"), auth, forward) + if err != nil { + return nil, err + } + return &socks5ContextDialer{d: d, timeout: opts.Timeout}, nil +} + +type socks5ContextDialer struct { + d xproxy.Dialer // lacks context; we wrap it + timeout time.Duration +} + +func (s *socks5ContextDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if !strings.HasPrefix(network, "tcp") { + return nil, errors.New("socks5 dialer only supports TCP") + } + // Best-effort context support: run the non-context dial in a goroutine + // and respect ctx cancellation/timeout. + type result struct { + c net.Conn + err error + } + ch := make(chan result, 1) + go func() { + c, err := s.d.Dial("tcp", address) + ch <- result{c: c, err: err} + }() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case r := <-ch: + return r.c, r.err + } +} + +// tlsWrappingDialer performs a TLS handshake over an existing base dialer. +type tlsWrappingDialer struct { + base ContextDialer + tcfg *tls.Config + timeout time.Duration +} + +func (t *tlsWrappingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if !strings.HasPrefix(network, "tcp") { + return nil, fmt.Errorf("tls dialer only supports TCP, got %q", network) + } + raw, err := t.base.DialContext(ctx, network, address) + if err != nil { + return nil, err + } + + // Apply deadline for handshake. + if t.timeout > 0 { + _ = raw.SetDeadline(time.Now().Add(t.timeout)) + } + + var h string + host := address + + if h, _, err = net.SplitHostPort(address); err == nil { + host = h + } + var cfg *tls.Config + if t.tcfg != nil { + // Clone to avoid copying internal locks and to prevent mutating caller's config. + c := t.tcfg.Clone() + if c.ServerName == "" { + c.ServerName = host + } + cfg = c + } else { + cfg = &tls.Config{ServerName: host} // #nosec G402 - intentional + } + + tlsConn := tls.Client(raw, cfg) + if err = tlsConn.HandshakeContext(ctx); err != nil { + _ = raw.Close() + return nil, err + } + + // Clear deadline after successful handshake + _ = tlsConn.SetDeadline(time.Time{}) + return tlsConn, nil +} + +func hostPortWithDefault(u *url.URL, defPort string) string { + host := u.Host + if !strings.Contains(host, ":") { + host += ":" + defPort + } + return host +} diff --git a/certlib/fetch.go b/lib/fetch.go similarity index 81% rename from certlib/fetch.go rename to lib/fetch.go index 957d1cd..2180d33 100644 --- a/certlib/fetch.go +++ b/lib/fetch.go @@ -1,18 +1,17 @@ -package certlib +package lib import ( "context" "crypto/tls" "crypto/x509" - "errors" "fmt" "io" "net" "os" + "git.wntrmute.dev/kyle/goutils/certlib" "git.wntrmute.dev/kyle/goutils/certlib/hosts" "git.wntrmute.dev/kyle/goutils/fileutil" - "git.wntrmute.dev/kyle/goutils/lib" ) // FetcherOpts are options for fetching certificates. They are only applicable to ServerFetcher. @@ -21,6 +20,13 @@ type FetcherOpts struct { Roots *x509.CertPool } +func (fo *FetcherOpts) TLSConfig() *tls.Config { + return &tls.Config{ + InsecureSkipVerify: fo.SkipVerify, // #nosec G402 - intentional + RootCAs: fo.Roots, + } +} + // Fetcher is an interface for fetching certificates from a remote source. It // currently supports fetching from a server or a file. type Fetcher interface { @@ -65,29 +71,20 @@ func ParseServer(host string) (*ServerFetcher, error) { } func (sf *ServerFetcher) String() string { - return fmt.Sprintf("tls://%s", net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1))) + return fmt.Sprintf("tls://%s", net.JoinHostPort(sf.host, Itoa(sf.port, -1))) } func (sf *ServerFetcher) GetChain() ([]*x509.Certificate, error) { - config := &tls.Config{ - InsecureSkipVerify: sf.insecure, // #nosec G402 - no shit sherlock - RootCAs: sf.roots, + opts := DialerOpts{ + TLSConfig: &tls.Config{ + InsecureSkipVerify: sf.insecure, // #nosec G402 - no shit sherlock + RootCAs: sf.roots, + }, } - dialer := &tls.Dialer{ - Config: config, - } - - hostSpec := net.JoinHostPort(sf.host, lib.Itoa(sf.port, -1)) - - netConn, err := dialer.DialContext(context.Background(), "tcp", hostSpec) + conn, err := DialTLS(context.Background(), net.JoinHostPort(sf.host, Itoa(sf.port, -1)), opts) if err != nil { - return nil, fmt.Errorf("dialing server: %w", err) - } - - conn, ok := netConn.(*tls.Conn) - if !ok { - return nil, errors.New("connection is not TLS") + return nil, fmt.Errorf("failed to dial server: %w", err) } defer conn.Close() @@ -125,10 +122,10 @@ func (ff *FileFetcher) GetChain() ([]*x509.Certificate, error) { return nil, fmt.Errorf("failed to read from stdin: %w", err) } - return ParseCertificatesPEM(certData) + return certlib.ParseCertificatesPEM(certData) } - certs, err := LoadCertificates(ff.path) + certs, err := certlib.LoadCertificates(ff.path) if err != nil { return nil, fmt.Errorf("failed to load chain: %w", err) } diff --git a/lib/lib.go b/lib/lib.go index e81451a..76f8634 100644 --- a/lib/lib.go +++ b/lib/lib.go @@ -1,4 +1,3 @@ -// Package lib contains functions useful for most programs. package lib import ( diff --git a/release-docker.sh b/release-docker.sh new file mode 100755 index 0000000..27a2fa3 --- /dev/null +++ b/release-docker.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# Release Docker image for kisom/goutils using the Dockerfile in the repo root. +# +# Behavior: +# - Determines the git tag that points to HEAD. If no tag points to HEAD, aborts. +# - Builds the Docker image from the top-level Dockerfile. +# - Tags the image as kisom/goutils: and kisom/goutils:latest. +# - Pushes both tags. +# +# Usage: +# ./release-docker.sh + +set -euo pipefail + +err() { printf "Error: %s\n" "$*" >&2; } +info() { printf "==> %s\n" "$*"; } + +# Ensure we're inside a git repository and operate from the repo root. +if ! REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then + err "This script must be run within a git repository." + exit 1 +fi +cd "$REPO_ROOT" + +IMAGE_REPO="kisom/goutils" +DOCKERFILE_PATH="$REPO_ROOT/Dockerfile" + +if [[ ! -f "$DOCKERFILE_PATH" ]]; then + err "Dockerfile not found at repository root: $DOCKERFILE_PATH" + err "Create a top-level Dockerfile or adjust this script before releasing." + exit 1 +fi + +# Find tags that point to HEAD. +if ! TAGS=$(git tag --points-at HEAD); then + err "Unable to query git tags." + exit 1 +fi + +if [[ -z "$TAGS" ]]; then + err "No git tag points at HEAD. Aborting release." + exit 1 +fi + +# Use the first tag if multiple are present; warn the user. +# Avoid readarray for broader Bash compatibility (e.g., macOS Bash 3.2). +TAG_ARRAY=($TAGS) +TAG="${TAG_ARRAY[0]}" + +if (( ${#TAG_ARRAY[@]} > 1 )); then + info "Multiple tags point at HEAD: ${TAG_ARRAY[*]}" + info "Using first tag: $TAG" +fi + +info "Releasing Docker image for tag: $TAG" + +IMAGE_TAGGED="$IMAGE_REPO:$TAG" +IMAGE_LATEST="$IMAGE_REPO:latest" + +info "Building image from $DOCKERFILE_PATH" +docker build -f "$DOCKERFILE_PATH" -t "$IMAGE_TAGGED" -t "$IMAGE_LATEST" "$REPO_ROOT" + +info "Pushing $IMAGE_TAGGED" +docker push "$IMAGE_TAGGED" + +info "Pushing $IMAGE_LATEST" +docker push "$IMAGE_LATEST" + +info "Release complete."