Day 60 — TLS awareness

Updated

July 30, 2026

Day 60 — TLS awareness

Stage VI · ~3h
Goal: Gain operational literacy for TLS in Go services—serve HTTPS locally with sane tls.Config, or document terminate-at-proxy with correct forwarded headers—and never casually disable verification.

Note

You are not becoming a PKI engineer today. You are becoming hard to fool: where TLS ends, what clients verify, and why InsecureSkipVerify is almost always wrong.

Why this day exists

Encryption in transit is table stakes. Go developers must know:

  • Where TLS terminates (app vs load balancer)
  • How to set minimum TLS version and cipher policy
  • How HTTP clients verify certificates
  • What InsecureSkipVerify actually does

Stage VI APIs that only speak cleartext on a laptop are incomplete for production mental models—even when the final deploy terminates TLS at a proxy.


Theory 1 — What TLS gives you

  1. Confidentiality — eavesdroppers don’t read payloads
  2. Integrity — tampering detected
  3. Authentication — server identity (and optionally client certs)

Certificates bind a public key to a name (SAN DNS/IP) via a chain of trust to a root CA. Modern clients match against Subject Alternative Name (SAN), not only the legacy Common Name (CN).

Client hello → Server cert chain → Verify name + chain → Keys → Encrypted HTTP

Theory 2 — Terminate at app vs proxy

[Client] --TLS--> [App :443]                    // app terminates

[Client] --TLS--> [LB/Proxy] --HTTP--> [App :8080]  // proxy terminates
Mode Pros Cons
App TLS End-to-end to process; simpler network trust Cert reload, CPU on app
Proxy TLS Central certs (ACME), WAF App must trust private network; need real IP headers

Many K8s setups terminate at ingress; apps speak cleartext inside the mesh or network policy. Document your trust boundary.

Choosing for the lab

Situation Pick
Learning tls.Config API Lab A (app TLS)
Already designing ingress Lab B (proxy doc)
Unsure Lab A first; add Lab B notes if time

Theory 3 — http.Server HTTPS

srv := &http.Server{
    Addr:              ":8443",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12, // prefer TLS 1.3 when clients allow
    },
}
err := srv.ListenAndServeTLS("cert.pem", "key.pem")

Or build the listener yourself:

ln, err := tls.Listen("tcp", ":8443", srv.TLSConfig)
if err != nil {
    return err
}
err = srv.Serve(ln)

Always keep ReadHeaderTimeout—TLS does not remove slowloris-style header tricks.

Generate local self-signed (lab)

openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30 -nodes \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

Browsers will warn; curl needs -k only for this self-signed lab when you have not installed the cert as a trusted CA.

# preferred lab proof (no skip-verify):
curl --cacert cert.pem https://127.0.0.1:8443/healthz

Theory 4 — Client verification

tr := &http.Transport{
    TLSClientConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
        // RootCAs: custom pool for private CA
    },
    // ForceHTTP2 / idle conns — tune later; defaults OK for lab
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}

Private CA

pemCerts, err := os.ReadFile("rootCA.pem")
if err != nil {
    return err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pemCerts) {
    return errors.New("no certs appended")
}
cfg := &tls.Config{
    RootCAs:    pool,
    MinVersion: tls.VersionTLS12,
    ServerName: "api.internal.example", // if dialing by IP
}

The footgun

TLSClientConfig: &tls.Config{InsecureSkipVerify: true} // MITM-able

Allowed only for ephemeral debug with eyes open—never default, never copy into prod configs. If tests need it, isolate:

// test helper only — file name tls_lab_test.go
func labInsecureClient() *http.Client {
    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lab only
        },
        Timeout: 5 * time.Second,
    }
}

Theory 5 — Forwarded headers (proxy mode)

When proxy terminates TLS:

X-Forwarded-Proto: https
X-Forwarded-For: 203.0.113.10, 10.0.0.5
X-Forwarded-Host: api.example.com

App must only trust these from the proxy IP. Spoofed X-Forwarded-For from the public internet is a classic auth/audit bug.

func realIP(r *http.Request, trustProxy bool) string {
    if trustProxy {
        if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
            return strings.TrimSpace(strings.Split(xff, ",")[0])
        }
    }
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return host
}

func isHTTPS(r *http.Request, trustProxy bool) bool {
    if r.TLS != nil {
        return true
    }
    if trustProxy && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
        return true
    }
    return false
}

Secure cookies: Secure: true when served over HTTPS (or when X-Forwarded-Proto is https and proxy is trusted).


Theory 6 — Redirect HTTP→HTTPS and HSTS

go func() {
    _ = http.ListenAndServe(":8080", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        host := r.Host
        target := "https://" + host + r.URL.RequestURI()
        http.Redirect(w, r, target, http.StatusMovedPermanently)
    }))
}()

HSTS header (careful on localhost—browsers remember):

w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")

Do not enable HSTS on throwaway lab hostnames you share with other apps.


Theory 7 — mTLS awareness (read-only depth)

Mutual TLS authenticates clients with certificates. Go sketch:

cfg := &tls.Config{
    MinVersion: tls.VersionTLS12,
    ClientAuth: tls.RequireAndVerifyClientCert,
    ClientCAs:  clientCAPool,
}

You will not implement a full PKI today. Know that service mesh (Istio, Linkerd) often does mTLS between proxies, not necessarily in your Go process.


Worked example — TLS server config baseline

func baselineTLS() *tls.Config {
    return &tls.Config{
        MinVersion: tls.VersionTLS12,
        CurvePreferences: []tls.CurveID{
            tls.X25519,
            tls.CurveP256,
        },
        // Prefer server cipher suites on TLS 1.2; TLS 1.3 has its own suite set
        // PreferServerCipherSuites is ignored for TLS 1.3
    }
}

Prefer TLS 1.3 when clients allow (MinVersion: tls.VersionTLS13 for hardened internal services).

Worked example — client with custom CA

func clientWithRoot(caPath string) (*http.Client, error) {
    pem, err := os.ReadFile(caPath)
    if err != nil {
        return nil, err
    }
    pool := x509.NewCertPool()
    if !pool.AppendCertsFromPEM(pem) {
        return nil, errors.New("append CA")
    }
    return &http.Client{
        Timeout: 10 * time.Second,
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{
                RootCAs:    pool,
                MinVersion: tls.VersionTLS12,
            },
        },
    }, nil
}

Lab options (pick A or B)

mkdir -p ~/lab/90daysofx/01-go/day60
cd ~/lab/90daysofx/01-go/day60
go mod init example.com/day60

Lab A — App-terminated HTTPS

  1. Generate self-signed certs with SAN for localhost + 127.0.0.1.
  2. Serve Stage VI API (or a minimal mux) on :8443 with MinVersion: TLS1_2.
  3. Client test using custom RootCAs or documented InsecureSkipVerify only in a test helper named // lab only.
  4. Prove curl --cacert cert.pem https://127.0.0.1:8443/healthz works.
  5. gitignore private keys (*.pem keys; keep sample cert generation script).

Lab B — Terminate-at-proxy design

  1. Write architecture doc: Caddy/nginx/envoy terminates TLS, app on 127.0.0.1:8080.
  2. Sample Caddyfile or nginx site snippet.
  3. App notes: trust hop, X-Forwarded-Proto, cookie Secure.
  4. Checklist for production cert automation (ACME).
  5. Explicit “app does not call ListenAndServeTLS” decision recorded.

Either lab fully done beats both half-done.

Sample Caddyfile (Lab B)

api.localhost {
    reverse_proxy 127.0.0.1:8080
}

Sample nginx sketch (Lab B)

server {
  listen 443 ssl;
  ssl_certificate     /etc/ssl/certs/fullchain.pem;
  ssl_certificate_key /etc/ssl/private/privkey.pem;
  location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}

Common gotchas

Gotcha Fix
CN only without SAN Modern clients want SAN
InsecureSkipVerify in prod Custom CA pool
Trusting X-Forwarded-* from world Proxy-only / network policy
Missing ReadHeaderTimeout on TLS server Still set it
Committing private keys gitignore *key*.pem
Mixed content / Secure cookie wrong Align with termination mode
Dial by IP with cert for DNS name Set ServerName or use the DNS name
TLS 1.0 still allowed by empty config Set MinVersion explicitly
“HTTPS so we skip auth” Orthogonal; auth is Day 54

Checkpoint

  • Can draw app vs proxy termination
  • Lab A or B complete
  • tls.Config MinVersion set
  • Know why skip-verify is dangerous
  • Keys not committed
  • Forwarded header trust story written
  • ReadHeaderTimeout present on HTTPS server

Commit

git add .
git commit -m "day60: TLS awareness HTTPS or proxy termination"

Write three personal gotchas before continuing.


Tomorrow

Day 61 — integration tests: real dependencies (SQLite/Postgres/docker/testcontainers) with cleanup discipline.