Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ environment variables that you can set.
| Variable Name | Description | Default Value |
|-----------------------------|---------------------------------------------------------|---------------|
| `TLS_DOMAIN` | Comma-separated list of domain names to use for TLS provisioning. If not set, TLS will be disabled. | None |
| `TLS_LOCAL` | Whether to use a self-signed certificate authority for TLS certificate provisioning. | Disabled |
| `TARGET_PORT` | The port that your Puma server should run on. Thruster will set `PORT` to this value when starting your server. | 3000 |
| `CACHE_SIZE` | The size of the HTTP cache in bytes. | 64MB |
| `MAX_CACHE_ITEM_SIZE` | The maximum size of a single item in the HTTP cache in bytes. | 1MB |
Expand Down
59 changes: 59 additions & 0 deletions internal/autocert_tls_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package internal

import (
"crypto/tls"
"encoding/base64"
"log/slog"
"net/http"

"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)

type AutocertTLSProvider struct {
manager *autocert.Manager
}

func NewAutocertTLSProvider(storagePath string, domains []string, acmeDirectoryURL string, eabKID string, eabHMACKey string) TLSProvider {
client := &acme.Client{DirectoryURL: acmeDirectoryURL}
binding := createExternalAccountBinding(eabKID, eabHMACKey)

slog.Debug("TLS: initializing autocert", "directory", client.DirectoryURL, "using_eab", binding != nil)

manager := &autocert.Manager{
Cache: autocert.DirCache(storagePath),
Client: client,
ExternalAccountBinding: binding,
HostPolicy: autocert.HostWhitelist(domains...),
Prompt: autocert.AcceptTOS,
}

return &AutocertTLSProvider{
manager: manager,
}
}

func (p *AutocertTLSProvider) HTTPHandler(h http.Handler) http.Handler {
return p.manager.HTTPHandler(h)
}

func (p *AutocertTLSProvider) TLSConfig() *tls.Config {
return p.manager.TLSConfig()
}

func createExternalAccountBinding(kid string, hmacKey string) *acme.ExternalAccountBinding {
if kid == "" || hmacKey == "" {
return nil
}

key, err := base64.RawURLEncoding.DecodeString(hmacKey)
if err != nil {
slog.Error("Error decoding EAB_HMACKey", "error", err)
return nil
}

return &acme.ExternalAccountBinding{
KID: kid,
Key: key,
}
}
97 changes: 97 additions & 0 deletions internal/autocert_tls_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package internal

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewAutocertTLSProvider(t *testing.T) {
tmpDir := t.TempDir()
domains := []string{"example.com", "www.example.com"}
acmeURL := "https://acme-staging-v02.api.letsencrypt.org/directory"

provider := NewAutocertTLSProvider(tmpDir, domains, acmeURL, "", "")

require.NotNil(t, provider)
}

func TestNewAutocertTLSProvider_WithEAB(t *testing.T) {
tmpDir := t.TempDir()
domains := []string{"example.com"}
acmeURL := "https://acme.zerossl.com/v2/DV90"
eabKID := "test-kid"
eabHMACKey := "dGVzdC1obWFjLWtleQ" // base64 encoded "test-hmac-key"

provider := NewAutocertTLSProvider(tmpDir, domains, acmeURL, eabKID, eabHMACKey)

require.NotNil(t, provider)
}

func TestAutocertTLSProvider_HTTPHandler(t *testing.T) {
tmpDir := t.TempDir()
domains := []string{"example.com"}
acmeURL := "https://acme-staging-v02.api.letsencrypt.org/directory"
provider := NewAutocertTLSProvider(tmpDir, domains, acmeURL, "", "")

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("test"))
require.NoError(t, err)
})

wrapped := provider.HTTPHandler(handler)
require.NotNil(t, wrapped)
}

func TestAutocertTLSProvider_TLSConfig(t *testing.T) {
tmpDir := t.TempDir()
domains := []string{"example.com", "www.example.com"}
acmeURL := "https://acme-staging-v02.api.letsencrypt.org/directory"
provider := NewAutocertTLSProvider(tmpDir, domains, acmeURL, "", "")

config := provider.TLSConfig()

require.NotNil(t, config)
assert.NotNil(t, config.GetCertificate)
assert.Contains(t, config.NextProtos, "h2")
assert.Contains(t, config.NextProtos, "http/1.1")
assert.Contains(t, config.NextProtos, "acme-tls/1")
}

func TestCreateExternalAccountBinding_ValidBase64(t *testing.T) {
kid := "test-kid"
hmacKey := "dGVzdC1obWFjLWtleQ" // base64 encoded "test-hmac-key"

binding := createExternalAccountBinding(kid, hmacKey)

require.NotNil(t, binding)
assert.Equal(t, kid, binding.KID)
assert.Equal(t, []byte("test-hmac-key"), binding.Key)
}

func TestCreateExternalAccountBinding_InvalidBase64(t *testing.T) {
kid := "test-kid"
hmacKey := "not-valid-base64!!!" // Invalid base64

binding := createExternalAccountBinding(kid, hmacKey)

// Should return nil on invalid base64
assert.Nil(t, binding)
}

func TestCreateExternalAccountBinding_EmptyInputs(t *testing.T) {
// Both empty
binding := createExternalAccountBinding("", "")
assert.Nil(t, binding)

// Only KID
binding = createExternalAccountBinding("test-kid", "")
assert.Nil(t, binding)

// Only HMAC key
binding = createExternalAccountBinding("", "dGVzdC1obWFjLWtleQ")
assert.Nil(t, binding)
}
4 changes: 3 additions & 1 deletion internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Config struct {
MaxRequestBody int

TLSDomains []string
TLSLocal bool
ACMEDirectoryURL string
EAB_KID string
EAB_HMACKey string
Expand Down Expand Up @@ -100,6 +101,7 @@ func NewConfig() (*Config, error) {
MaxRequestBody: getEnvInt("MAX_REQUEST_BODY", defaultMaxRequestBody),

TLSDomains: getEnvStrings("TLS_DOMAIN", []string{}),
TLSLocal: getEnvBool("TLS_LOCAL", false),
ACMEDirectoryURL: getEnvString("ACME_DIRECTORY", defaultACMEDirectoryURL),
EAB_KID: getEnvString("EAB_KID", ""),
EAB_HMACKey: getEnvString("EAB_HMAC_KEY", ""),
Expand All @@ -124,7 +126,7 @@ func NewConfig() (*Config, error) {
}

func (c *Config) HasTLS() bool {
return len(c.TLSDomains) > 0
return len(c.TLSDomains) > 0 || c.TLSLocal
}

func findEnv(key string) (string, bool) {
Expand Down
19 changes: 19 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,20 @@ func TestConfig_tls(t *testing.T) {
assert.False(t, c.HasTLS())
assert.False(t, c.ForwardHeaders)
})

t.Run("with TLS_LOCAL", func(t *testing.T) {
usingProgramArgs(t, "thruster", "echo", "hello")
usingEnvVar(t, "TLS_DOMAIN", "")
usingEnvVar(t, "TLS_LOCAL", "true")

c, err := NewConfig()
require.NoError(t, err)

assert.Equal(t, []string{}, c.TLSDomains)
assert.True(t, c.HasTLS())
assert.False(t, c.ForwardHeaders)
assert.True(t, c.TLSLocal)
})
}

func TestConfig_defaults(t *testing.T) {
Expand All @@ -106,6 +120,7 @@ func TestConfig_defaults(t *testing.T) {
assert.Equal(t, defaultCacheSize, c.CacheSizeBytes)
assert.Equal(t, slog.LevelInfo, c.LogLevel)
assert.Equal(t, false, c.H2CEnabled)
assert.Equal(t, false, c.TLSLocal)
}

func TestConfig_override_defaults_with_env_vars(t *testing.T) {
Expand All @@ -119,6 +134,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
usingEnvVar(t, "ACME_DIRECTORY", "https://acme-staging-v02.api.letsencrypt.org/directory")
usingEnvVar(t, "LOG_REQUESTS", "false")
usingEnvVar(t, "H2C_ENABLED", "true")
usingEnvVar(t, "TLS_LOCAL", "true")
usingEnvVar(t, "GZIP_COMPRESSION_DISABLE_ON_AUTH", "true")
usingEnvVar(t, "GZIP_COMPRESSION_JITTER", "64")

Expand All @@ -134,6 +150,7 @@ func TestConfig_override_defaults_with_env_vars(t *testing.T) {
assert.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", c.ACMEDirectoryURL)
assert.Equal(t, false, c.LogRequests)
assert.Equal(t, true, c.H2CEnabled)
assert.Equal(t, true, c.TLSLocal)
assert.Equal(t, true, c.GzipCompressionDisableOnAuth)
assert.Equal(t, 64, c.GzipCompressionJitter)
}
Expand All @@ -147,6 +164,7 @@ func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) {
usingEnvVar(t, "THRUSTER_DEBUG", "1")
usingEnvVar(t, "THRUSTER_LOG_REQUESTS", "0")
usingEnvVar(t, "THRUSTER_H2C_ENABLED", "1")
usingEnvVar(t, "THRUSTER_TLS_LOCAL", "1")

c, err := NewConfig()
require.NoError(t, err)
Expand All @@ -158,6 +176,7 @@ func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) {
assert.Equal(t, slog.LevelDebug, c.LogLevel)
assert.Equal(t, false, c.LogRequests)
assert.Equal(t, true, c.H2CEnabled)
assert.Equal(t, true, c.TLSLocal)
}

func TestConfig_prefixed_variables_take_precedence_over_non_prefixed(t *testing.T) {
Expand Down
Loading