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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,33 @@ root path. Services deployed to other paths on the same host will use the same
TLS settings as those specified for the root path.


### On-demand TLS

Instead of specifying a static list of hosts, Kamal Proxy can also obtain TLS
certificates dynamically, for any host approved by an HTTP endpoint of your
choice. This is useful when the full set of hosts is not known at deploy time,
such as when serving customer domains.

To enable this, specify `--tls-on-demand-url` (instead of `--host`) when
deploying:

kamal-proxy deploy service1 --target web-1:3000 --tls --tls-on-demand-url="http://localhost:4567/check"

The URL may be:

- An external URL (like `http://localhost:4567/check`), which Kamal Proxy will
call directly, or
- A path (like `/check`), which Kamal Proxy will route through the service to
your application, letting the application decide which hosts to allow.

Before issuing a certificate for a host, Kamal Proxy will send a `GET` request
to the endpoint, with the hostname in a `host` query parameter (for example,
`?host=app1.example.com`) and matching `Host` header. A `200` response allows
certificate issuance; any other response denies it, and the status code and up
to 256 bytes of the response body are logged to help with debugging. Checks
time out after 2 seconds, denying issuance for that attempt.


### Custom TLS certificate

When you obtained your TLS certificate manually, manage your own certificate authority,
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.StripPrefix, "strip-path-prefix", true, "With --path-prefix, strip prefix from request before forwarding")

deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSEnabled, "tls", false, "Configure TLS for this target (requires a non-empty host)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSOnDemandURL, "tls-on-demand-url", "", "Will make an HTTP request to the given URL, asking whether a host is allowed to have a certificate issued")
Comment thread
kevinmcconnell marked this conversation as resolved.
deployCommand.cmd.Flags().BoolVar(&deployCommand.tlsStaging, "tls-staging", false, "Use Let's Encrypt staging environment for certificate provisioning")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSCertificatePath, "tls-certificate-path", "", "Configure custom TLS certificate path (PEM format)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSPrivateKeyPath, "tls-private-key-path", "", "Configure custom TLS private key path (PEM format)")
Expand Down
31 changes: 31 additions & 0 deletions internal/cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ func TestDeployCommand_TLSRequiresHost(t *testing.T) {
assertTLSHostValidation(t, []string{"example.com", "*.example.com"}, true)
}

func TestDeployCommand_TLSOnDemandURL(t *testing.T) {
t.Run("host is not required when a TLS on-demand URL is set", func(t *testing.T) {
cmd := newDeployCommand()
cmd.args.ServiceOptions.TLSEnabled = true
cmd.args.ServiceOptions.TLSOnDemandURL = "https://example.com/allow-host"

require.NoError(t, cmd.preRun(cmd.cmd, []string{"test-service"}))
})

t.Run("hosts cannot be combined with a TLS on-demand URL", func(t *testing.T) {
cmd := newDeployCommand()
cmd.args.ServiceOptions.TLSEnabled = true
cmd.args.ServiceOptions.TLSOnDemandURL = "https://example.com/allow-host"
cmd.args.ServiceOptions.Hosts = []string{"example.com"}

err := cmd.preRun(cmd.cmd, []string{"test-service"})
require.ErrorContains(t, err, "cannot set hosts when using a TLS on-demand URL")
require.ErrorIs(t, err, server.ErrServiceOptionsInvalid)
})

t.Run("the TLS on-demand URL must be valid", func(t *testing.T) {
cmd := newDeployCommand()
cmd.args.ServiceOptions.TLSEnabled = true
cmd.args.ServiceOptions.TLSOnDemandURL = "ftp://example.com/allow-host"

err := cmd.preRun(cmd.cmd, []string{"test-service"})
require.ErrorContains(t, err, "unsupported scheme")
require.ErrorIs(t, err, server.ErrServiceOptionsInvalid)
})
}

func TestDeployCommand_CanonicalHostValidation(t *testing.T) {
tests := []struct {
name string
Expand Down
38 changes: 38 additions & 0 deletions internal/server/router_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
Expand All @@ -11,6 +12,8 @@ import (
"testing"
"time"

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -803,6 +806,41 @@ func TestRouter_RestoreLastSavedState(t *testing.T) {
assert.Equal(t, "third", body)
}

func TestRouter_RestoreLastSavedState_TLSOnDemandURL(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.json")

allowServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("host") == "allowed.example.com" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusForbidden)
}))
defer allowServer.Close()

_, target := testBackend(t, "first", http.StatusOK)

serviceOptions := defaultServiceOptions
serviceOptions.TLSEnabled = true
serviceOptions.TLSOnDemandURL = allowServer.URL

router := NewRouter(statePath)
require.NoError(t, router.DeployService("ondemand", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions))

router = NewRouter(statePath)
require.NoError(t, router.RestoreLastSavedState())

service := router.services.Get("ondemand")
require.NotNil(t, service)

manager, ok := service.certManager.(*autocert.Manager)
require.True(t, ok)
require.NotNil(t, manager.HostPolicy)

assert.NoError(t, manager.HostPolicy(context.Background(), "allowed.example.com"))
assert.Error(t, manager.HostPolicy(context.Background(), "denied.example.com"))
}

// Helpers

func testRouter(t *testing.T) *Router {
Expand Down
99 changes: 78 additions & 21 deletions internal/server/service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -56,8 +57,22 @@ var (
ErrorUnableToLoadErrorPages = errors.New("unable to load error pages")
ErrorAutomaticTLSDoesNotSupportWildcards = errors.New("automatic TLS does not support wildcards")
ErrServiceOptionsInvalid = errors.New("service options invalid")

contextKeyInternalRequest = contextKey("internal-request")
)

// markInternalRequest marks the context as belonging to an internal request:
// one synthesized inside the proxy itself, such as a TLS on-demand check
// probe, rather than arriving over a client connection.
func markInternalRequest(ctx context.Context) context.Context {
return context.WithValue(ctx, contextKeyInternalRequest, true)
}

func isInternalRequest(r *http.Request) bool {
internal, _ := r.Context().Value(contextKeyInternalRequest).(bool)
return internal
}

type TargetSlot int

const (
Expand Down Expand Up @@ -85,6 +100,7 @@ type ServiceOptions struct {
TLSEnabled bool `json:"tls_enabled"`
TLSCertificatePath string `json:"tls_certificate_path"`
TLSPrivateKeyPath string `json:"tls_private_key_path"`
TLSOnDemandURL string `json:"tls_on_demand_url"`
TLSRedirect bool `json:"tls_redirect"`
CanonicalHost string `json:"canonical_host"`
ACMEDirectory string `json:"acme_directory"`
Expand All @@ -109,8 +125,28 @@ func (so *ServiceOptions) Normalize() {
func (so ServiceOptions) Validate() error {
so.Normalize()

if so.TLSOnDemandURL != "" && !so.TLSEnabled {
return fmt.Errorf("%w: TLS must be enabled to use a TLS on-demand URL", ErrServiceOptionsInvalid)
}

if so.TLSEnabled {
if !so.HasConfiguredHosts() {
if so.TLSOnDemandURL != "" {
if so.HasConfiguredHosts() {
return fmt.Errorf("%w: cannot set hosts when using a TLS on-demand URL", ErrServiceOptionsInvalid)
}

if so.TLSCertificatePath != "" || so.TLSPrivateKeyPath != "" {
return fmt.Errorf("%w: cannot use a custom TLS certificate with a TLS on-demand URL", ErrServiceOptionsInvalid)
}

if so.CanonicalHost != "" {
return fmt.Errorf("%w: cannot set a canonical host when using a TLS on-demand URL", ErrServiceOptionsInvalid)
}

if err := validateTLSOnDemandURL(so.TLSOnDemandURL); err != nil {
return fmt.Errorf("%w: %w", ErrServiceOptionsInvalid, err)
}
} else if !so.HasConfiguredHosts() {
return fmt.Errorf("%w: host must be set when using TLS", ErrServiceOptionsInvalid)
}

Expand Down Expand Up @@ -430,14 +466,33 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error)
}
}

certCache := autocert.DirCache(options.ScopedCachePath())

hostPolicy, err := s.createHostPolicy(options, certCache)
if err != nil {
return nil, err
}

return &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(options.ScopedCachePath()),
HostPolicy: autocert.HostWhitelist(options.Hosts...),
Cache: certCache,
HostPolicy: hostPolicy,
Client: &acme.Client{DirectoryURL: options.ACMEDirectory},
}, nil
}

func (s *Service) createHostPolicy(options ServiceOptions, certCache autocert.Cache) (autocert.HostPolicy, error) {
if options.TLSOnDemandURL != "" {
checker, err := newTLSOnDemandChecker(s, options.TLSOnDemandURL, certCache)
if err != nil {
return nil, err
}
return checker.hostPolicy(), nil
}

return autocert.HostWhitelist(options.Hosts...), nil
}

func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) {
var err error
var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget)
Expand Down Expand Up @@ -533,28 +588,30 @@ func (s *Service) handleRedirectsIfNeeded(w http.ResponseWriter, r *http.Request
// TLS redirection or canonical host redirection should occur. If no redirect is
// needed, it returns an empty string.
func (s *Service) redirectURLIfNeeded(r *http.Request) string {
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
if !isInternalRequest(r) {
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}

currentScheme := "http"
if r.TLS != nil {
currentScheme = "https"
}
currentScheme := "http"
if r.TLS != nil {
currentScheme = "https"
}

desiredScheme := currentScheme
if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" {
desiredScheme = "https"
}
desiredScheme := currentScheme
if s.options.TLSEnabled && s.options.TLSRedirect && currentScheme == "http" {
desiredScheme = "https"
}

desiredHost := host
if s.options.CanonicalHost != "" && host != s.options.CanonicalHost {
desiredHost = s.options.CanonicalHost
}
desiredHost := host
if s.options.CanonicalHost != "" && host != s.options.CanonicalHost {
desiredHost = s.options.CanonicalHost
}

if desiredScheme != currentScheme || desiredHost != host {
return desiredScheme + "://" + desiredHost + r.URL.RequestURI()
if desiredScheme != currentScheme || desiredHost != host {
return desiredScheme + "://" + desiredHost + r.URL.RequestURI()
}
}

return ""
Expand Down
2 changes: 1 addition & 1 deletion internal/server/service_map.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func (m *ServiceMap) updateRequestServiceMap() {

func (m *ServiceMap) updateDefaultTLSHostname() {
for _, service := range m.services {
if service.options.TLSEnabled && len(service.options.Hosts) > 0 {
if service.options.TLSEnabled && len(service.options.Hosts) > 0 && service.options.Hosts[0] != "" {
m.defaultTLSHostname = service.options.Hosts[0]
return
}
Expand Down
13 changes: 13 additions & 0 deletions internal/server/service_map_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ func TestServiceMap_DefaultTLSHostname(t *testing.T) {
assert.Equal(t, "example.com", sm.DefaultTLSHostname())
}

func TestServiceMap_DefaultTLSHostnameIgnoresOnDemandTLSServices(t *testing.T) {
sm := NewServiceMap()
sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"})})
assert.Empty(t, sm.DefaultTLSHostname())

sm.Set(&Service{name: "2", options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true})})
assert.Equal(t, "example.com", sm.DefaultTLSHostname())

// Re-setting the on-demand service must not displace the default hostname.
sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"})})
assert.Equal(t, "example.com", sm.DefaultTLSHostname())
}

func TestServiceMap_SyncingTLSSettingsFromRootPath(t *testing.T) {
sm := NewServiceMap()
sm.Set(&Service{name: "1", options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"1.example.com"}, TLSEnabled: true, TLSRedirect: false})})
Expand Down
11 changes: 11 additions & 0 deletions internal/server/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,17 @@ func TestServiceOptions_Validate(t *testing.T) {

assertNotValid(ServiceOptions{Hosts: []string{"example.com", "www.example.com"}, CanonicalHost: "api.example.com"}, "canonical-host 'api.example.com' must be present in the hosts list: [example.com www.example.com]")
assertValid(ServiceOptions{Hosts: []string{"example.com", "www.example.com"}, CanonicalHost: "www.example.com"})

assertValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host"})
assertValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "https://example.com/allow-host"})
assertNotValid(ServiceOptions{Hosts: []string{"example.com"}, TLSEnabled: true, TLSOnDemandURL: "/allow-host"}, "cannot set hosts when using a TLS on-demand URL")
assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "ftp://example.com/allow-host"}, "unsupported scheme")
assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "://invalid-url"}, "unable to parse tls-on-demand-url")
assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "//example.com/allow-host"}, "must be a path or an absolute http(s) URL")
assertNotValid(ServiceOptions{PathPrefixes: []string{"/api"}, TLSEnabled: true, TLSOnDemandURL: "/allow-host"}, "TLS settings must be specified on the root path service")
assertNotValid(ServiceOptions{TLSOnDemandURL: "/allow-host"}, "TLS must be enabled to use a TLS on-demand URL")
assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host", TLSCertificatePath: "cert.pem", TLSPrivateKeyPath: "key.pem"}, "cannot use a custom TLS certificate with a TLS on-demand URL")
assertNotValid(ServiceOptions{TLSEnabled: true, TLSOnDemandURL: "/allow-host", CanonicalHost: "example.com"}, "cannot set a canonical host when using a TLS on-demand URL")
}

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