Skip to content

feat(resp): add STARTTLS connection upgrades - #24

Merged
Saxy merged 4 commits into
Saxy:mainfrom
404khai:feat/resp-starttls
Aug 1, 2026
Merged

feat(resp): add STARTTLS connection upgrades#24
Saxy merged 4 commits into
Saxy:mainfrom
404khai:feat/resp-starttls

Conversation

@404khai

@404khai 404khai commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an explicit, opt-in STARTTLS upgrade path for RESP connections. When --resp-starttls (or TSD_RESP_STARTTLS) is enabled, the RESP listener begins in plaintext, accepts STARTTLS, flushes a plaintext +OK, and then performs a TLS 1.3 handshake on the same socket.

Existing behavior remains secure and backward compatible: implicit TLS is still the default when TLS material is configured, and the binary protocol continues to require TLS from the first byte. STARTTLS requires both --tls-cert and --tls-key, so an invalid deployment fails during configuration loading rather than accepting plaintext without an available upgrade.

Component: Networking/RESP

Type of Change:

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Performance optimization (no change in behavior, improved speed/memory)
  • Refactoring (no functional changes, code cleanup)
  • Build / CI / Documentation

Related Issue

Closes #15


Technical Deep Dive & Context

Configuration and compatibility

  • Adds --resp-starttls and TSD_RESP_STARTTLS; both default to disabled.
  • Requires configured certificate and key material when STARTTLS is enabled.
  • Preserves implicit TLS as the default RESP behavior instead of introducing an optional-TLS mode that could silently downgrade existing encrypted deployments.
  • Leaves the binary listener's implicit TLS behavior unchanged.
  • Adds no dependencies.

Connection upgrade

A successful STARTTLS command follows this sequence on the gnet event loop:

  1. Verify that TLS configuration is available and append the plaintext +OK response.
  2. Atomically load the latest TLS configuration from ConfigStore for this upgrade.
  3. Write and explicitly flush +OK to the socket.
  4. Discard the consumed plaintext RESP frame.
  5. Install TLS state and require the client to begin a TLS 1.3 handshake.

Loading ConfigStore before writing +OK, rather than when the plaintext connection is accepted, means the server fails closed if no TLS configuration is available and an idle client receives the latest successfully rotated certificate when it eventually upgrades. Established TLS sessions retain their existing TLS state. Implicit TLS and STARTTLS share the same named 10-second handshake timeout so their deadline behavior cannot drift.

STARTTLS is handled before the authentication gate, allowing clients to encrypt the connection before sending credentials. Authentication state is preserved across the transport transition. A repeated STARTTLS command on an encrypted connection returns -ERR connection is already encrypted.

Plaintext boundary and pipelining

STARTTLS must be the only plaintext command in the current inbound buffer. The server preflights the buffer before dispatching any command and closes the connection when a valid STARTTLS frame has preceding or trailing plaintext bytes. This prevents commands in the same buffer from executing across the transport-security boundary. Rejected transitions increment the existing RESP protocol-error counter.

The preflight is guarded by the opt-in feature flag, so disabled deployments do not parse requests a second time. The normal authenticated GET and SET dispatch path does not evaluate STARTTLS handling.


Performance & Benchmarks

Workload: Server.dispatch microbenchmarks for a GET hit and plain SET, darwin/arm64, Apple M3, five runs per benchmark. Baseline is merged upstream commit d51e187; before and after were run back-to-back from separate worktrees.

Metric Before After Delta
GET hit mean 31.286 ns/op 31.282 ns/op -0.01%
GET hit allocations 0 B/op, 0 allocs/op 0 B/op, 0 allocs/op unchanged
SET mean 52.042 ns/op 52.900 ns/op +1.65%
SET allocations 16 B/op, 1 alloc/op 16 B/op, 1 alloc/op unchanged

No new allocations were introduced. GET was effectively unchanged; SET measured approximately 1 ns/op slower in this five-run microbenchmark. No end-to-end throughput or latency benchmark was run.

$ go test -run='^$' -bench='BenchmarkDispatch(GetHit|Set)$' -benchmem -count=5 ./internal/resp

=== baseline d51e187 ===
goos: darwin
goarch: arm64
pkg: github.com/Saxy/Tellstone/internal/resp
cpu: Apple M3
BenchmarkDispatchGetHit-8    36854517    31.44 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    38890118    31.35 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    37997329    31.16 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    38246554    31.31 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    38007660    31.17 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchSet-8       22719903    52.04 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       23194998    51.85 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22956602    51.94 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       23105007    51.91 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22727074    52.47 ns/op    16 B/op    1 allocs/op
PASS

=== feat/resp-starttls ===
goos: darwin
goarch: arm64
pkg: github.com/Saxy/Tellstone/internal/resp
cpu: Apple M3
BenchmarkDispatchGetHit-8    38098915    31.25 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    38719305    31.19 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    37805098    31.32 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    37888903    31.28 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchGetHit-8    38337840    31.37 ns/op     0 B/op    0 allocs/op
BenchmarkDispatchSet-8       21566425    52.84 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22674361    53.07 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22567790    52.92 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22714850    52.82 ns/op    16 B/op    1 allocs/op
BenchmarkDispatchSet-8       22722896    52.85 ns/op    16 B/op    1 allocs/op
PASS

How Has This Been Tested?

The following commands passed locally:

go test -count=10 ./internal/resp -run STARTTLS
go test ./config ./internal/resp
go test ./...
go test -race ./internal/resp ./config ./server
go test -race ./...
go vet ./...
git diff --check

The optional task CLI was not installed, so the documented direct Go equivalents were used for formatting, vetting, and race testing.

Integration coverage uses a real standard-library TLS 1.3 client and generated test certificate. It verifies:

  • STARTTLS succeeds before AUTH.
  • Commands remain protected by AUTH after the TLS transition.
  • AUTH and PING succeed over the upgraded connection.
  • Wrong STARTTLS arity returns the expected RESP error.
  • Repeated STARTTLS over TLS is rejected.
  • A plaintext connection opened before certificate rotation receives the newly published certificate when it upgrades.
  • Commands before or after STARTTLS in the same plaintext buffer are not executed, the connection is closed, and the protocol-error metric is incremented.
  • Existing implicit RESP TLS remains the default when STARTTLS is disabled.
  • STARTTLS remains an unknown command in implicit-TLS mode.
  • Configuration is disabled by default, supports flags and environment variables, and fails fast without TLS material.

Checklist

  • My code follows the existing code style of this project
  • I have added tests that prove my fix/feature works
  • New and existing tests pass locally (go test ./... and go test -race ./...)
  • I have updated the documentation (README, comments, or any relevant docs)
  • My changes generate no new go vet warnings
  • Any breaking changes are documented and communicated

This change is opt-in and introduces no breaking behavior changes.

Summary by CodeRabbit

New Features

  • Added optional RESP STARTTLS support via --resp-starttls or TSD_RESP_STARTTLS.
  • RESP connections can begin unencrypted, upgrade to TLS, and authenticate afterward.
  • Existing implicit TLS behavior remains available when STARTTLS is disabled.
  • Added validation requiring TLS certificates and keys for STARTTLS.
  • STARTTLS rejects repeated or pipelined upgrade attempts.

Documentation

  • Documented configuration, authentication ordering, TLS upgrades, certificate rotation, and RESP commands.
  • Updated the TLS roadmap status.

Keep implicit TLS as the default and reject plaintext pipelining
across the RESP transport upgrade.

Closes Saxy#15

Signed-off-by: Oghenefega Daniel Omajene <omajeneoghenefega11@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6ca037b-0d6a-4915-8db6-3f38a8908b49

📥 Commits

Reviewing files that changed from the base of the PR and between 8b8f8f3 and 001e57d.

📒 Files selected for processing (1)
  • .gitignore

📝 Walkthrough

Walkthrough

Added opt-in RESP STARTTLS configuration. RESP connections can upgrade from plaintext to TLS 1.3 before authentication. The server rejects pipelined upgrades, preserves implicit TLS behavior, loads current TLS configuration, and includes end-to-end tests and documentation.

Changes

RESP STARTTLS

Layer / File(s) Summary
STARTTLS configuration contract
config/config.go, config/config_test.go
Added the resp-starttls flag, TSD_RESP_STARTTLS, TLS-material validation, public accessor, and configuration tests.
RESP STARTTLS upgrade flow
internal/resp/server.go, server/server.go, internal/resp/server_test.go
Added plaintext startup, STARTTLS dispatch, +OK flushing, TLS configuration loading, handshake setup, authentication ordering, pipelining rejection, and server wiring.
Protocol validation and documentation
internal/resp/starttls_test.go, README.md, ARCHITECTURE.md, ROADMAP.md, .gitignore
Added end-to-end coverage for upgrades, authentication, TLS 1.3, certificate rotation, pipelining, repeated commands, and implicit TLS. Updated protocol and roadmap documentation. Added .DS_Store to ignored files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RESPClient
  participant RESPServer
  participant TLSConfigStore
  RESPClient->>RESPServer: Send STARTTLS
  RESPServer-->>RESPClient: Return plaintext +OK
  RESPServer->>TLSConfigStore: Load current TLS configuration
  RESPServer-->>RESPClient: Start TLS 1.3 handshake
  RESPClient->>RESPServer: Complete handshake
  RESPClient->>RESPServer: Send AUTH and commands
Loading

Possibly related PRs

  • Saxy/Tellstone#17: Extends RESP authentication flow to allow STARTTLS before authentication.
  • Saxy/Tellstone#21: Extends the existing ConfigStore TLS plumbing for per-connection STARTTLS upgrades.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The .gitignore update for .DS_Store is unrelated to the STARTTLS objectives in issue #15. Remove the unrelated .DS_Store entry from .gitignore, or provide a repository-level justification for including it.
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: adding RESP STARTTLS connection upgrades.
Description check ✅ Passed The description follows the template and covers scope, implementation, testing, benchmarks, documentation, and checklist items.
Linked Issues check ✅ Passed The implementation satisfies issue #15 by adding opt-in RESP STARTTLS, flushed plaintext acknowledgment, TLS upgrades, error handling, compatibility, and boundary protection.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
internal/resp/starttls_test.go (1)

119-152: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for certificate rotation during the plaintext STARTTLS window.

upgradeToTLS in internal/resp/server.go deliberately calls s.tlsConfigs.Load() at upgrade time rather than caching the value from OnOpen, so that an idle plaintext connection observes a rotated certificate. This is called out explicitly in ARCHITECTURE.md and the PR objectives, but no test in this file connects, rotates the ConfigStore to a second certificate, then issues STARTTLS and verifies the client sees the new certificate. Add a test that swaps the store's config between connect and upgrade.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/resp/starttls_test.go` around lines 119 - 152, Add a STARTTLS
certificate-rotation test using startRESPTLSServer and its returned ConfigStore,
keeping a plaintext connection open, replacing the store configuration with a
second certificate before issuing STARTTLS, and asserting the client receives
the rotated certificate rather than the original one.
internal/resp/server.go (2)

381-415: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the handshake deadline into a shared constant.

upgradeToTLS repeats the same 10 * time.Second literal already used for implicit TLS in OnOpen (line 167). Extract one named constant so both call sites stay in sync if the timeout changes.

♻️ Proposed fix
+const tlsHandshakeTimeout = 10 * time.Second
+
 func (s *Server) upgradeToTLS(c gnet.Conn, st *connState, consumed int) gnet.Action {
 	...
 	adapter := tlslib.NewGnetConnAdapter(c)
 	st.tlsConn = tlslib.Server(adapter, tlsCfg)
 	st.readBuf = make([]byte, 0, 4096)
-	st.handshakeDeadline = time.Now().Add(10 * time.Second)
+	st.handshakeDeadline = time.Now().Add(tlsHandshakeTimeout)
 	st.upgradeTLS = false
 	return gnet.None
 }

Also update the implicit-TLS setup outside this range:

// OnOpen, line ~167
st.handshakeDeadline = time.Now().Add(tlsHandshakeTimeout)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/resp/server.go` around lines 381 - 415, Define a shared named
constant for the TLS handshake timeout and replace the 10-second literal in
upgradeToTLS when assigning st.handshakeDeadline. Also update the implicit-TLS
setup in OnOpen to use the same constant, keeping both handshake deadline paths
synchronized.

421-500: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist the STARTTLS check to remove duplication.

s.startTLS && EqualFold(cmd, "STARTTLS") is checked twice: once pre-auth and once in the default case. Since dispatchSTARTTLS does not depend on st.authenticated, move the check above the !st.authenticated gate and drop the default-case copy.

♻️ Proposed fix
 	cmd := args[0]
 	if EqualFold(cmd, shard.CmdAuth) {
 		return s.auth(st, args, out)
 	}
 	// STARTTLS precedes the authentication gate so credentials can remain encrypted.
+	if s.startTLS && EqualFold(cmd, "STARTTLS") {
+		return s.dispatchSTARTTLS(st, args, out)
+	}
 	if !st.authenticated {
-		if s.startTLS && EqualFold(cmd, "STARTTLS") {
-			return s.dispatchSTARTTLS(st, args, out)
-		}
 		// Unauthenticated connections may only issue AUTH, PING, and QUIT (Redis semantics).
 		if !EqualFold(cmd, shard.CmdPing) && !EqualFold(cmd, "QUIT") {
 			return AppendError(out, "NOAUTH Authentication required")
 		}
 	}
 	switch {
 	...
 	default:
-		if s.startTLS && EqualFold(cmd, "STARTTLS") {
-			return s.dispatchSTARTTLS(st, args, out)
-		}
 		return AppendError(out, "ERR unknown command '"+string(cmd)+"'")
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/resp/server.go` around lines 421 - 500, Update Server.dispatch to
handle the s.startTLS && EqualFold(cmd, "STARTTLS") condition once before the
!st.authenticated gate, then remove the duplicate STARTTLS branch from the
switch default case while preserving dispatchSTARTTLS behavior for both
authenticated and unauthenticated connections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ARCHITECTURE.md`:
- Around line 160-173: Update the TLS rotation description in the “TLS Transport
and Certificate Rotation” section so RESP STARTTLS loads the shared TLS
configuration before flushing the plaintext +OK response, matching the ordering
in upgradeToTLS. Preserve the surrounding handshake and established-connection
behavior.

---

Nitpick comments:
In `@internal/resp/server.go`:
- Around line 381-415: Define a shared named constant for the TLS handshake
timeout and replace the 10-second literal in upgradeToTLS when assigning
st.handshakeDeadline. Also update the implicit-TLS setup in OnOpen to use the
same constant, keeping both handshake deadline paths synchronized.
- Around line 421-500: Update Server.dispatch to handle the s.startTLS &&
EqualFold(cmd, "STARTTLS") condition once before the !st.authenticated gate,
then remove the duplicate STARTTLS branch from the switch default case while
preserving dispatchSTARTTLS behavior for both authenticated and unauthenticated
connections.

In `@internal/resp/starttls_test.go`:
- Around line 119-152: Add a STARTTLS certificate-rotation test using
startRESPTLSServer and its returned ConfigStore, keeping a plaintext connection
open, replacing the store configuration with a second certificate before issuing
STARTTLS, and asserting the client receives the rotated certificate rather than
the original one.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a0a99f55-f70f-4b16-a43f-e8a7af16f2c1

📥 Commits

Reviewing files that changed from the base of the PR and between d51e187 and d47fc87.

⛔ Files ignored due to path filters (1)
  • .DS_Store is excluded by !**/.DS_Store
📒 Files selected for processing (9)
  • ARCHITECTURE.md
  • README.md
  • ROADMAP.md
  • config/config.go
  • config/config_test.go
  • internal/resp/server.go
  • internal/resp/server_test.go
  • internal/resp/starttls_test.go
  • server/server.go

Comment thread ARCHITECTURE.md
Share the TLS handshake timeout and correct the documented upgrade order.

Signed-off-by: Oghenefega Daniel Omajene <omajeneoghenefega11@gmail.com>
Saxy
Saxy previously approved these changes Jul 31, 2026

@Saxy Saxy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

just remove the DS_STORE

some nitpick:

The pipelining-guard edge case: a trailing incomplete command after STARTTLS (e.g. STARTTLS\r\n + *1\r\n$4\r\nPIN) isn't rejected by hasPipelinedSTARTTLS — the bytes cross into the TLS layer, the handshake fails, and the connection closes.

"ERR TLS not configured" is technically due to panic not reachable

not blocking

Comment thread .DS_Store Outdated
@Saxy

Saxy commented Jul 31, 2026

Copy link
Copy Markdown
Owner

created follow up #25

Signed-off-by: Oghenefega Daniel Omajene <omajeneoghenefega11@gmail.com>
Signed-off-by: Oghenefega Daniel Omajene <omajeneoghenefega11@gmail.com>
@Saxy
Saxy self-requested a review August 1, 2026 10:59
@Saxy
Saxy merged commit b6192dc into Saxy:main Aug 1, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

STARTTLS Upgrade Path for RESP

2 participants