Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 @@ -51,6 +51,7 @@ type Config struct {
MaxRequestBody int

TLSDomains []string
TLSLocal bool
ACMEDirectoryURL string
EAB_KID string
EAB_HMACKey string
Expand Down Expand Up @@ -93,6 +94,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 @@ -117,7 +119,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")

c, err := NewConfig()
require.NoError(t, err)
Expand All @@ -132,6 +148,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)
}

func TestConfig_override_defaults_with_env_vars_using_prefix(t *testing.T) {
Expand All @@ -143,6 +160,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 @@ -154,6 +172,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