Day 60 — TLS awareness
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.
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
InsecureSkipVerifyactually 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
- Confidentiality — eavesdroppers don’t read payloads
- Integrity — tampering detected
- 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/healthzTheory 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-ableAllowed 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/day60Lab A — App-terminated HTTPS
- Generate self-signed certs with SAN for localhost + 127.0.0.1.
- Serve Stage VI API (or a minimal mux) on
:8443withMinVersion: TLS1_2.
- Client test using custom
RootCAsor documentedInsecureSkipVerifyonly in a test helper named// lab only.
- Prove
curl --cacert cert.pem https://127.0.0.1:8443/healthzworks.
gitignoreprivate keys (*.pemkeys; keep sample cert generation script).
Lab B — Terminate-at-proxy design
- Write architecture doc: Caddy/nginx/envoy terminates TLS, app on
127.0.0.1:8080.
- Sample Caddyfile or nginx site snippet.
- App notes: trust hop,
X-Forwarded-Proto, cookie Secure.
- Checklist for production cert automation (ACME).
- 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.ConfigMinVersion 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.