Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,35 @@ or `--tls-domains-source` — serves every hostname no other service claims, so
client CA applies to all of them.


### Minimum TLS version

The HTTPS listener negotiates TLS 1.2 and above by default. To refuse TLS 1.2 as
well and serve only TLS 1.3, start the proxy with `--min-tls` (or the `MIN_TLS`
environment variable):

kamal-proxy run --min-tls 1.3

Accepted values are `1.2` and `1.3`. TLS 1.0 and 1.1 cannot be enabled — they are
deprecated by [RFC 8996](https://www.rfc-editor.org/rfc/rfc8996) and Go's TLS
stack already declines to serve them, so the proxy refuses to start rather than
pretend the setting took effect. The `tls1_2` / `tls1_3` spellings are accepted
too, so a configuration written against upstream kamal-proxy keeps working.

This is a listener-wide setting: it applies to every service, including hosts
that require client certificates. The HTTP/3 listener is always TLS 1.3, since
QUIC is only defined over TLS 1.3 ([RFC 9001](https://www.rfc-editor.org/rfc/rfc9001)),
and `--min-tls` neither lowers nor raises it.

Raising the minimum locks out clients that cannot reach it, and they fail during
the handshake — before the request reaches the HTTP layer, so nothing appears in
the access log. Check what your clients actually negotiate before setting `1.3`.

Cipher suites are deliberately not configurable. Go does not allow selecting TLS
1.3 cipher suites at all, and its TLS 1.2 defaults already exclude the RC4, 3DES
and static-RSA suites that a hardening baseline asks you to remove — so a cipher
flag could only weaken the proxy, while silently doing nothing on TLS 1.3.


### SAN Certificate Batching

When started with `--acme-email` (or the `ACME_EMAIL` environment variable),
Expand Down
19 changes: 16 additions & 3 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ type runCommand struct {
func newRunCommand() *runCommand {
runCommand := &runCommand{}
runCommand.cmd = &cobra.Command{
Use: "run",
Short: "Run the server",
RunE: runCommand.run,
Use: "run",
Short: "Run the server",
PreRunE: runCommand.preRun,
RunE: runCommand.run,
}

runCommand.cmd.Flags().BoolVar(&runCommand.debugLogsEnabled, "debug", getEnvBool("DEBUG", false), "Include debugging logs")
Expand All @@ -51,6 +52,7 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().DurationVar(&globalConfig.ShutdownTimeout, "shutdown-timeout", getEnvDuration("SHUTDOWN_TIMEOUT", server.DefaultShutdownTimeout), "Maximum time to wait for in-flight requests to drain on shutdown")

// ACME/TLS configuration
runCommand.cmd.Flags().StringVar(&globalConfig.MinTLS, "min-tls", getEnvString("MIN_TLS", server.DefaultMinTLSVersion), "Lowest TLS version the HTTPS listener will negotiate: 1.2 or 1.3 (TLS 1.0 and 1.1 cannot be enabled; HTTP/3 is always 1.3)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL")
runCommand.cmd.Flags().StringVar(&runCommand.acmeDNSProvider, "acme-dns-provider", getEnvString("ACME_DNS_PROVIDER", "auto"), "DNS provider for DNS-01 challenges (cloudflare, route53, digitalocean, gcloud, namecheap, godaddy, hetzner, vultr, auto)")
Expand All @@ -60,6 +62,17 @@ func newRunCommand() *runCommand {
return runCommand
}

// preRun rejects a bad --min-tls before the command restores routing state or
// registers an ACME account, so a typo costs a millisecond rather than a round
// trip to Let's Encrypt. The listener parses it again at bind time.
func (c *runCommand) preRun(cmd *cobra.Command, args []string) error {
if _, err := server.ParseMinTLSVersion(globalConfig.MinTLS); err != nil {
return err
}

return nil
}

func (c *runCommand) run(cmd *cobra.Command, args []string) error {
c.setLogger()

Expand Down
64 changes: 64 additions & 0 deletions internal/cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,67 @@ func TestGetEnvDuration(t *testing.T) {
})
}
}

func TestRunCommand_MinTLSFlag(t *testing.T) {
// This feature teaches operators to export MIN_TLS, so a developer running the
// suite is unusually likely to have it set. The prefixed key wins in findEnv,
// so pinning it here makes the default deterministic. Setting MIN_TLS to ""
// would not work: findEnv reports an empty value as present.
t.Setenv("KAMAL_PROXY_MIN_TLS", server.DefaultMinTLSVersion)

globalConfig = server.Config{}

cmd := newRunCommand().cmd

flag := cmd.Flags().Lookup("min-tls")
require.NotNil(t, flag)
assert.Equal(t, server.DefaultMinTLSVersion, flag.DefValue)

require.NoError(t, cmd.Flags().Parse([]string{"--min-tls=1.3"}))
assert.Equal(t, "1.3", globalConfig.MinTLS)
}

func TestRunCommand_MinTLSPreRun(t *testing.T) {
tests := []struct {
name string
value string
expectedError string
}{
// The accepted spellings have to be exercised through the command, not
// just through ParseMinTLSVersion: preRun is what an operator's value
// actually passes through, and the README promises upstream's tls1_2
// grammar keeps working here.
{name: "default", value: server.DefaultMinTLSVersion},
{name: "1.3", value: "1.3"},
{name: "upstream tls1_2 grammar", value: "tls1_2"},
{name: "upstream tls1_3 grammar", value: "tls1_3"},
{name: "TLSv prefix", value: "TLSv1.3"},

{name: "TLS 1.0", value: "1.0", expectedError: "cannot be enabled"},
{name: "TLS 1.1", value: "1.1", expectedError: "cannot be enabled"},
{name: "upstream tls1_0 grammar", value: "tls1_0", expectedError: "cannot be enabled"},
{name: "not a version", value: "banana", expectedError: "is not a TLS version"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
globalConfig = server.Config{}

runCommand := newRunCommand()
require.NoError(t, runCommand.cmd.Flags().Parse([]string{"--min-tls=" + tt.value}))

err := runCommand.preRun(runCommand.cmd, nil)

if tt.expectedError != "" {
require.ErrorContains(t, err, tt.expectedError)
require.ErrorContains(t, err, "min-tls")
return
}

require.NoError(t, err)
// The server parses this same string again at bind time, so what
// preRun accepted has to be what reaches the listener.
assert.Equal(t, tt.value, globalConfig.MinTLS)
})
}
}
6 changes: 6 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ type Config struct {
MetricsPort int
HTTP3Enabled bool

// MinTLS is the lowest TLS version the HTTPS listener will negotiate,
// written as "1.2" or "1.3". Empty means 1.2, which is also Go's own
// minimum, so this setting can only ever narrow what the listener accepts -
// ParseMinTLSVersion refuses TLS 1.0 and 1.1 outright.
MinTLS string

// MetricsAllowIPs restricts the metrics endpoint to these addresses and CIDR
// ranges. Empty (the default) serves everyone that can reach the port.
MetricsAllowIPs []string
Expand Down
17 changes: 17 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ func (s *Server) startHTTP3Server(handler http.Handler, httpsAddr string) error
}

http3Config := &tls.Config{
// QUIC is defined only over TLS 1.3 (RFC 9001), so --min-tls can
// neither lower this listener nor needs to raise it.
MinVersion: tls.VersionTLS13,
NextProtos: []string{"h3"},
GetCertificate: s.router.GetCertificate,
Expand All @@ -234,6 +236,13 @@ func (s *Server) startHTTPServers() error {
return err
}

// Parsed here too, not just in the run command, so a Config built directly
// cannot start a listener that silently ignores the setting.
minTLSVersion, err := ParseMinTLSVersion(s.config.MinTLS)
if err != nil {
return err
}

httpAddr := fmt.Sprintf("%s:%d", s.config.Bind, s.config.HttpPort)
httpsAddr := fmt.Sprintf("%s:%d", s.config.Bind, s.config.HttpsPort)

Expand Down Expand Up @@ -265,12 +274,20 @@ func (s *Server) startHTTPServers() error {
handler.ServeHTTP(w, r)
}))
httpsConfig := &tls.Config{
MinVersion: minTLSVersion,
NextProtos: []string{"h2", "http/1.1", acme.ALPNProto},
GetCertificate: s.router.GetCertificate,
}
httpsConfig.GetConfigForClient = s.clientCertificateConfig(httpsConfig)
s.httpsServer.TLSConfig = httpsConfig

if minTLSVersion > tls.VersionTLS12 {
// Worth a line in the boot log: it is the operator's only confirmation
// that the narrowing took effect, and clients it locks out fail in the
// handshake with nothing to show for it at the HTTP layer.
slog.Info("Minimum TLS version raised", "min_tls", tls.VersionName(minTLSVersion))
}

if s.config.ProxyProtocol {
slog.Info("PROXY protocol enabled", "allow", s.config.ProxyProtocolAllowIPs)

Expand Down
52 changes: 52 additions & 0 deletions internal/server/tls_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package server

import (
"crypto/tls"
"fmt"
"strings"
)

// DefaultMinTLSVersion is the minimum version the HTTPS listener negotiates
// when --min-tls is not given. It matches Go's own server-side minimum, so the
// default states an intent rather than changing behavior.
const DefaultMinTLSVersion = "1.2"

// ParseMinTLSVersion maps a --min-tls value onto a crypto/tls version constant.
//
// Only 1.2 and 1.3 are accepted. The flag exists to narrow what the listener
// will negotiate for a compliance scanner, never to widen it: Go already
// refuses TLS 1.0 and 1.1, and accepting them here would make a hardening flag
// the only way to downgrade the proxy. An empty value means the default, so an
// unset or blank MIN_TLS boots instead of failing.
func ParseMinTLSVersion(value string) (uint16, error) {
if strings.TrimSpace(value) == "" {
return tls.VersionTLS12, nil
}

switch normalized := normalizeTLSVersion(value); normalized {
case "1.2":
return tls.VersionTLS12, nil
case "1.3":
return tls.VersionTLS13, nil
case "1.0", "1.1":
return 0, fmt.Errorf("min-tls: TLS %s cannot be enabled; the lowest accepted minimum is %s", normalized, DefaultMinTLSVersion)
default:
return 0, fmt.Errorf("min-tls: %q is not a TLS version; use %s or 1.3", value, DefaultMinTLSVersion)
}
}

// normalizeTLSVersion reduces the spellings operators actually type - and the
// tls1_2 form used by basecamp/kamal-proxy#199 - to a bare "1.2"/"1.3", so a
// config written for upstream keeps working here.
func normalizeTLSVersion(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))

for _, prefix := range []string{"tlsv", "tls"} {
if trimmed, found := strings.CutPrefix(normalized, prefix); found {
normalized = trimmed
break
}
}

return strings.ReplaceAll(normalized, "_", ".")
}
Loading
Loading