Compare commits

...

11 Commits

18 changed files with 683 additions and 91 deletions

View File

@@ -419,14 +419,9 @@ builds:
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
# archive filename: name_version_os_arch
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
@@ -439,13 +434,10 @@ changelog:
- "^docs:"
- "^test:"
gitea_urls:
api: https://git.wntrmute.dev/api/v1
download: https://git.wntrmute.dev
# set to true if you use a self-signed certificate
skip_tls_verify: false
release:
github:
owner: kisom
name: goutils
footer: >-
---

View File

@@ -1,5 +1,56 @@
CHANGELOG
v1.14.1 - 2025-11-18
Added:
- build: add missing Dockerfile.
v1.14.0 - 2025-11-18
Added:
- lib/dialer: introduce proxy-aware dialers and helpers:
- NewNetDialer and NewTLSDialer honoring SOCKS5_PROXY, HTTPS_PROXY, HTTP_PROXY
(case-insensitive) with precedence SOCKS5 > HTTPS > HTTP.
- DialTCP and DialTLS convenience functions; DialTLS performs a TLS handshake
and returns a concrete *tls.Conn.
- NewHTTPClient: returns a proxy-aware *http.Client. Uses SOCKS5 proxy when
configured (disables HTTP(S) proxying to avoid double-proxying); otherwise
relies on http.ProxyFromEnvironment (respects HTTP(S)_PROXY and NO_PROXY).
- build: the releasse-docker.sh builds and pushes the correct Docker images.
Changed:
- cmd: migrate tools to new proxy-aware helpers where appropriate:
- certchain, stealchain, tlsinfo: use lib.DialTLS.
- cert-revcheck: use lib.DialTLS for site connects and a proxy-aware
HTTP client for OCSP/CRL fetches.
- rhash: use proxy-aware HTTP client for downloads.
- lib/fetch: migrate from certlib/fetch.go to lib/fetch.go and use DialTLS
under the hood.
- go.mod: add golang.org/x/net dependency (for SOCKS5 support) and align x/crypto.
Notes:
- HTTP(S) proxy CONNECT supports optional basic auth via proxy URL credentials.
- HTTPS proxies are TLS-wrapped prior to CONNECT.
- Timeouts apply to TCP connects, proxy handshakes, and TLS handshakes; context
cancellation is honored.
- Some commands retain bespoke dialing (e.g., IPv6-only or unix sockets) and
were intentionally left unchanged.
v1.13.6 - 2025-11-18
Changed:
- build: removing gitea stuff.
v1.13.5 - 2025-11-18
Changed:
- build: updating goreleaser config.
v1.13.4 - 2025-11-18
Changed:
- build: updating goreleaser config.
v1.13.3 - 2025-11-18
Added:

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
# ----- Builder stage: build all cmd/... tools -----
FROM golang:1.24-alpine AS builder
# Install necessary build dependencies for fetching modules
RUN apk add --no-cache git ca-certificates && update-ca-certificates
WORKDIR /src
# Cache modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy the rest of the source
COPY . .
# Build and install all commands under ./cmd/... into /out
ENV CGO_ENABLED=0
ENV GOBIN=/out
RUN --mount=type=cache,target=/go/pkg/mod \
go install ./cmd/...
# ----- Final runtime image: minimal alpine with tools installed -----
FROM alpine:3.20
# Ensure common utilities are present
RUN apk add --no-cache bash curl ca-certificates && update-ca-certificates
# Copy binaries from builder
COPY --from=builder /out/ /usr/local/bin/
# Working directory for mounting the host CWD
WORKDIR /work
# Default command shows available tools if run without args
CMD ["/bin/sh", "-lc", "echo 'Tools installed:' && ls -1 /usr/local/bin && echo '\nMount your project with: docker run --rm -it -v $PWD:/work IMAGE <tool> ...'"]

View File

@@ -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] <target> [<target>...]\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")
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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()

3
go.mod
View File

@@ -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
)

6
go.sum
View File

@@ -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=

452
lib/dialer.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -1,4 +1,3 @@
// Package lib contains functions useful for most programs.
package lib
import (

70
release-docker.sh Executable file
View File

@@ -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:<TAG> 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."