Compare commits

...

13 Commits

20 changed files with 853 additions and 134 deletions

View File

@@ -247,11 +247,12 @@ linters:
# Default: false
check-type-assertions: true
exclude-functions:
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
- (*git.wntrmute.dev/kyle/goutils/dbg.DebugPrinter).Write
- git.wntrmute.dev/kyle/goutils/lib.Warn
- git.wntrmute.dev/kyle/goutils/lib.Warnx
- git.wntrmute.dev/kyle/goutils/lib.Err
- git.wntrmute.dev/kyle/goutils/lib.Errx
- (*git.wntrmute.dev/kyle/goutils/sbuf.Buffer).Write
exhaustive:
# Program elements to check for exhaustiveness.

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,67 @@
CHANGELOG
v1.14.2 - 2025-11-18
Added:
- lib: add tooling for generating baseline TLS configs.
Changed:
- cmd: update all commands to allow the use strict TLS configs. Note that
many of these tools are intended for debugging broken or insecure TLS
systems, and the ability to support insecure TLS configurations is
important in this regard.
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

@@ -7,6 +7,7 @@ import (
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"flag"
@@ -17,7 +18,6 @@ import (
"github.com/kr/text"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/lib"
)
@@ -351,14 +351,11 @@ func main() {
flag.BoolVar(&leafOnly, "l", false, "only show the leaf certificate")
flag.Parse()
opts := &certlib.FetcherOpts{
SkipVerify: true,
Roots: nil,
}
tlsCfg := &tls.Config{InsecureSkipVerify: true} // #nosec G402 - tool intentionally inspects broken TLS
for _, filename := range flag.Args() {
fmt.Fprintf(os.Stdout, "--%s ---%s", filename, "\n")
certs, err := certlib.GetCertificateChain(filename, opts)
certs, err := lib.GetCertificateChain(filename, tlsCfg)
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"
)
@@ -64,6 +63,7 @@ func checkCert(cert *x509.Certificate) {
warn := inDanger(cert)
name := displayName(cert.Subject)
name = fmt.Sprintf("%s/SN=%s", name, cert.SerialNumber)
expiry := expires(cert)
if warnOnly {
if warn {
@@ -75,15 +75,22 @@ func checkCert(cert *x509.Certificate) {
}
func main() {
opts := &certlib.FetcherOpts{}
var skipVerify bool
var strictTLS bool
lib.StrictTLSFlag(&strictTLS)
flag.BoolVar(&opts.SkipVerify, "k", false, "skip server verification")
flag.BoolVar(&skipVerify, "k", false, "skip server verification") // #nosec G402
flag.BoolVar(&warnOnly, "q", false, "only warn about expiring certs")
flag.DurationVar(&leeway, "t", leeway, "warn if certificates are closer than this to expiring")
flag.Parse()
tlsCfg, err := lib.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
for _, file := range flag.Args() {
certs, err := certlib.GetCertificateChain(file, opts)
var certs []*x509.Certificate
certs, err = lib.GetCertificateChain(file, tlsCfg)
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,16 +31,23 @@ func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
}
func main() {
opts := &certlib.FetcherOpts{}
var skipVerify bool
var strictTLS bool
lib.StrictTLSFlag(&strictTLS)
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")
flag.BoolVar(&skipVerify, "k", false, "skip server verification") // #nosec G402
flag.Parse()
tlsCfg, err := lib.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
displayMode := parseDisplayMode(*displayAs)
for _, arg := range flag.Args() {
cert, err := certlib.GetCertificate(arg, opts)
var cert *x509.Certificate
cert, err = lib.GetCertificate(arg, tlsCfg)
die.If(err)
fmt.Printf("%s: %s", arg, serialString(cert, displayMode))

View File

@@ -32,6 +32,7 @@ type appConfig struct {
caFile, intFile string
forceIntermediateBundle bool
revexp, skipVerify, verbose bool
strictTLS bool
}
func parseFlags() appConfig {
@@ -43,6 +44,7 @@ func parseFlags() appConfig {
flag.BoolVar(&cfg.skipVerify, "k", false, "skip CA verification")
flag.BoolVar(&cfg.revexp, "r", false, "print revocation and expiry information")
flag.BoolVar(&cfg.verbose, "v", false, "verbose")
lib.StrictTLSFlag(&cfg.strictTLS)
flag.Parse()
return cfg
}
@@ -108,12 +110,13 @@ func run(cfg appConfig) error {
return fmt.Errorf("failed to build combined pool: %w", err)
}
opts := &certlib.FetcherOpts{
Roots: combinedPool,
SkipVerify: cfg.skipVerify,
tlsCfg, err := lib.BaselineTLSConfig(cfg.skipVerify, cfg.strictTLS)
if err != nil {
return err
}
tlsCfg.RootCAs = combinedPool
chain, err := certlib.GetCertificateChain(flag.Arg(0), opts)
chain, err := lib.GetCertificateChain(flag.Arg(0), tlsCfg)
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

@@ -3,55 +3,48 @@ package main
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"flag"
"fmt"
"net"
"os"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
func main() {
var cfg = &tls.Config{} // #nosec G402
var sysRoot, serverName string
var skipVerify bool
var strictTLS bool
lib.StrictTLSFlag(&strictTLS)
flag.StringVar(&sysRoot, "ca", "", "provide an alternate CA bundle")
flag.StringVar(&cfg.ServerName, "sni", cfg.ServerName, "provide an SNI name")
flag.BoolVar(&cfg.InsecureSkipVerify, "noverify", false, "don't verify certificates")
flag.StringVar(&serverName, "sni", "", "provide an SNI name")
flag.BoolVar(&skipVerify, "noverify", false, "don't verify certificates")
flag.Parse()
tlsCfg, err := lib.BaselineTLSConfig(skipVerify, strictTLS)
die.If(err)
if sysRoot != "" {
pemList, err := os.ReadFile(sysRoot)
tlsCfg.RootCAs, err = certlib.LoadPEMCertPool(sysRoot)
die.If(err)
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemList) {
fmt.Printf("[!] no valid roots found")
roots = nil
}
cfg.RootCAs = roots
}
if serverName != "" {
cfg.ServerName = serverName
tlsCfg.ServerName = serverName
}
for _, site := range flag.Args() {
_, _, err := net.SplitHostPort(site)
_, _, err = net.SplitHostPort(site)
if err != nil {
site += ":443"
}
d := &tls.Dialer{Config: cfg}
nc, err := d.DialContext(context.Background(), "tcp", site)
die.If(err)
conn, ok := nc.(*tls.Conn)
if !ok {
die.With("invalid TLS connection (not a *tls.Conn)")
}
var conn *tls.Conn
conn, err = lib.DialTLS(context.Background(), site, lib.DialerOpts{TLSConfig: tlsCfg})
die.If(err)
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()

View File

@@ -1,12 +1,34 @@
// Package dbg implements a debug printer.
// Package dbg implements a simple debug printer.
//
// There are two main ways to use it:
// - By using one of the constructors and calling flag.BoolVar(&debug.Enabled...)
// - By setting the environment variable GOUTILS_ENABLE_DEBUG to true or false and
// calling NewFromEnv().
//
// If enabled, any of the print statements will be written to stdout. Otherwise,
// nothing will be emitted.
package dbg
import (
"fmt"
"io"
"os"
"runtime/debug"
"strings"
)
const DebugEnvKey = "GOUTILS_ENABLE_DEBUG"
var enabledValues = map[string]bool{
"1": true,
"true": true,
"yes": true,
"on": true,
"y": true,
"enable": true,
"enabled": true,
}
// A DebugPrinter is a drop-in replacement for fmt.Print*, and also acts as
// an io.WriteCloser when enabled.
type DebugPrinter struct {
@@ -15,6 +37,23 @@ type DebugPrinter struct {
out io.WriteCloser
}
// New returns a new DebugPrinter on os.Stdout.
func New() *DebugPrinter {
return &DebugPrinter{
out: os.Stderr,
}
}
// NewFromEnv returns a new DebugPrinter based on the value of the environment
// variable GOUTILS_ENABLE_DEBUG.
func NewFromEnv() *DebugPrinter {
enabled := strings.ToLower(os.Getenv(DebugEnvKey))
return &DebugPrinter{
out: os.Stderr,
Enabled: enabledValues[enabled],
}
}
// Close satisfies the Closer interface.
func (dbg *DebugPrinter) Close() error {
return dbg.out.Close()
@@ -28,13 +67,6 @@ func (dbg *DebugPrinter) Write(p []byte) (int, error) {
return 0, nil
}
// New returns a new DebugPrinter on os.Stdout.
func New() *DebugPrinter {
return &DebugPrinter{
out: os.Stdout,
}
}
// ToFile sets up a new DebugPrinter to a file, truncating it if it exists.
func ToFile(path string) (*DebugPrinter, error) {
file, err := os.Create(path)
@@ -74,3 +106,7 @@ func (dbg *DebugPrinter) Printf(format string, v ...any) {
fmt.Fprintf(dbg.out, format, v...)
}
}
func (dbg *DebugPrinter) StackTrace() {
dbg.Write(debug.Stack())
}

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=

525
lib/dialer.go Normal file
View File

@@ -0,0 +1,525 @@
// 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"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
xproxy "golang.org/x/net/proxy"
"git.wntrmute.dev/kyle/goutils/dbg"
)
// StrictBaselineTLSConfig returns a secure TLS config.
// Many of the tools in this repo are designed to debug broken TLS systems
// and therefore explicitly support old or insecure TLS setups.
func StrictBaselineTLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: false, // explicitly set
}
}
func StrictTLSFlag(useStrict *bool) {
flag.BoolVar(useStrict, "strict-tls", false, "Use strict TLS configuration (disables certificate verification)")
}
func BaselineTLSConfig(skipVerify bool, secure bool) (*tls.Config, error) {
if secure && skipVerify {
return nil, errors.New("cannot skip verification and use secure TLS")
}
if skipVerify {
return &tls.Config{InsecureSkipVerify: true}, nil // #nosec G402 - intentional
}
if secure {
return StrictBaselineTLSConfig(), nil
}
return &tls.Config{}, nil // #nosec G402 - intentional
}
var debug = dbg.NewFromEnv()
// 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 {
debug.Printf("using SOCKS5 proxy %q\n", u)
return newSOCKS5Dialer(u, opts)
}
if u := getProxyURLFromEnv("HTTPS_PROXY"); u != nil {
// Respect the proxy URL scheme. Zscaler may set HTTPS_PROXY to an HTTP proxy
// running locally; in that case we must NOT TLS-wrap the proxy connection.
debug.Printf("using HTTPS proxy %q\n", u)
return &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}, nil
}
if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil {
debug.Printf("using HTTP proxy %q\n", u)
return &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
// Only TLS-wrap the proxy connection if the URL scheme is https.
secure: strings.EqualFold(u.Scheme, "https"),
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 {
debug.Printf("using SOCKS5 proxy %q\n", u)
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 {
debug.Printf("using HTTPS proxy %q\n", u)
base := &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
config: opts.TLSConfig,
}
return &tlsWrappingDialer{base: base, tcfg: opts.TLSConfig, timeout: opts.Timeout}, nil
}
if u := getProxyURLFromEnv("HTTP_PROXY"); u != nil {
debug.Printf("using HTTP proxy %q\n", u)
base := &httpProxyDialer{
proxyURL: u,
timeout: opts.Timeout,
secure: strings.EqualFold(u.Scheme, "https"),
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
}
// proxyAddress returns host:port for the proxy, applying defaults by scheme when missing.
func (d *httpProxyDialer) proxyAddress() string {
proxyAddr := d.proxyURL.Host
if !strings.Contains(proxyAddr, ":") {
if d.secure {
proxyAddr += ":443"
} else {
proxyAddr += ":80"
}
}
return proxyAddr
}
// tlsWrapProxyConn performs a TLS handshake to the proxy when d.secure is true.
// It clones the provided tls.Config (if any), ensures ServerName and a safe
// minimum TLS version.
func (d *httpProxyDialer) tlsWrapProxyConn(ctx context.Context, conn net.Conn) (net.Conn, error) {
host := d.proxyURL.Hostname()
// Clone provided config (if any) to avoid mutating caller's config.
cfg := &tls.Config{} // #nosec G402 - intentional
if d.config != nil {
cfg = d.config.Clone()
}
if cfg.ServerName == "" {
cfg.ServerName = host
}
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.HandshakeContext(ctx); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("tls handshake with https proxy failed: %w", err)
}
return tlsConn, nil
}
// readConnectResponse reads and validates the proxy's response to a CONNECT
// request. It returns nil on a 200 status and an error otherwise.
func readConnectResponse(br *bufio.Reader) error {
statusLine, err := br.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read CONNECT response: %w", err)
}
if !strings.HasPrefix(statusLine, "HTTP/") {
return 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)
return fmt.Errorf("proxy CONNECT failed: %s", strings.TrimSpace(statusLine))
}
return drainHeaders(br)
}
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}
conn, err := nd.DialContext(ctx, "tcp", d.proxyAddress())
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 {
c, werr := d.tlsWrapProxyConn(ctx, conn)
if werr != nil {
return nil, werr
}
conn = c
}
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)
if err = readConnectResponse(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, MinVersion: tls.VersionTLS12}
}
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,25 +1,21 @@
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.
type FetcherOpts struct {
SkipVerify bool
Roots *x509.CertPool
}
// Note: Previously this package exposed a FetcherOpts type. It has been
// refactored to use *tls.Config directly for configuring TLS behavior.
// Fetcher is an interface for fetching certificates from a remote source. It
// currently supports fetching from a server or a file.
@@ -65,29 +61,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 +112,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)
}
@@ -146,7 +133,10 @@ func (ff *FileFetcher) Get() (*x509.Certificate, error) {
}
// GetCertificateChain fetches a certificate chain from a remote source.
func GetCertificateChain(spec string, opts *FetcherOpts) ([]*x509.Certificate, error) {
// If cfg is non-nil and spec refers to a TLS server, the provided TLS
// configuration will be used to control verification behavior (e.g.,
// InsecureSkipVerify, RootCAs).
func GetCertificateChain(spec string, cfg *tls.Config) ([]*x509.Certificate, error) {
if fileutil.FileDoesExist(spec) {
return NewFileFetcher(spec).GetChain()
}
@@ -156,17 +146,17 @@ func GetCertificateChain(spec string, opts *FetcherOpts) ([]*x509.Certificate, e
return nil, err
}
if opts != nil {
fetcher.insecure = opts.SkipVerify
fetcher.roots = opts.Roots
if cfg != nil {
fetcher.insecure = cfg.InsecureSkipVerify
fetcher.roots = cfg.RootCAs
}
return fetcher.GetChain()
}
// GetCertificate fetches the first certificate from a certificate chain.
func GetCertificate(spec string, opts *FetcherOpts) (*x509.Certificate, error) {
certs, err := GetCertificateChain(spec, opts)
func GetCertificate(spec string, cfg *tls.Config) (*x509.Certificate, error) {
certs, err := GetCertificateChain(spec, cfg)
if err != nil {
return nil, 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."