Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b807ef5
implement the On-Demand TLS functionality
did Nov 1, 2024
fbbcdd3
remove debugging statements and old code
did Nov 1, 2024
92398c0
the on-demand URL must contain the http scheme
did Nov 1, 2024
d22e6a3
Update README.md
did Nov 3, 2024
24ab71c
don't check for the len of hosts when evaluating the TLSOnDemandUrl
did Nov 30, 2024
89ad217
chore: lint the code
did Dec 2, 2024
80cd7f9
chore: remove a debug message
did Dec 2, 2024
3247a51
chore: better logging when contacting the on demand url failed + writ…
did Jul 9, 2025
25a7d09
chore: remove a warning about a wrong type
did Jul 10, 2025
08de3cd
feat: no need to pass the host config attribute if tls-on-demand-url …
did Jul 10, 2025
4160a10
feat: allow path and external url for the TLSOnDemandURL option
did Jul 11, 2025
adf8772
chore: update the README.md based on the new path functionality
did Jul 11, 2025
30eadc4
fix: don't pass a Nil body when contacting a local host
did Aug 8, 2025
6daf93a
fix: new options were not correctly applied when restoring the state
did Feb 25, 2026
7fa1b2d
fix: treat local TLS on-demand checks as HTTPS when redirects are ena…
did Feb 26, 2026
65bef1e
fix: the previous didn't work in production, use another approach by …
did Feb 26, 2026
a55cfa5
chore: satisfy Copilot code review (WIP)
did Mar 4, 2026
bb45ac3
Update internal/server/service_test.go
did Mar 4, 2026
78f89e8
Update internal/server/tls_on_demand.go
did Mar 4, 2026
d588d81
chore: don't bypass the path prefix validation check
did Mar 4, 2026
af47399
Update internal/server/router_test.go
did Mar 4, 2026
d0228bc
chore: rename TLSOnDemandUrl into TLSOnDemandURL
did Mar 4, 2026
fec3323
chore: parse and merge query params instead of string-concatenating:
did Mar 4, 2026
3844ebd
Update internal/server/service.go
did Mar 4, 2026
0b33d56
Update internal/server/tls_on_demand.go
did Mar 4, 2026
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
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ 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

In addition to the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate
for any host allowed by an external API endpoint of your choice. This avoids hard-coding hosts in the configuration, especially when you don't know the hosts at startup.

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

The On-demand URL endpoint must return a 200 HTTP status code to allow certificate issuance.
Kamal Proxy will call the on-demand URL with a query string parameter `host` containing the hostname received by Kamal Proxy (for example, `?host=hostname.example.com`).

- The HTTP request to the on-demand URL will time out after 2 seconds. If the endpoint is unreachable or slow, certificate issuance will fail for that host.
- If the endpoint returns any status other than 200, Kamal Proxy will log the status code and up to 256 bytes of the response body for debugging.
- **Security note:** The on-demand URL acts as an authorization gate for certificate issuance. It should be protected and only allow trusted hosts. If compromised, unauthorized certificates could be issued.
- If `--tls-on-demand-url` is not set, Kamal Proxy falls back to a static whitelist of hosts.
- If using a local path (for example, `/allow-host`), ensure that endpoint is reachable through your deployed app and returns quickly.

**Best practice:**
- Ensure your on-demand endpoint is fast, reliable, and protected (e.g., behind authentication or on a private network).
- Only allow hosts you control to prevent abuse.

Example endpoint logic (pseudo-code):

if host in allowed_hosts:
return 200 OK
else:
return 403 Forbidden


### Custom TLS certificate

When you obtained your TLS certificate manually, manage your own certificate authority,
Expand All @@ -143,6 +171,29 @@ your certificate file and the corresponding private key:
kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem


## TLSOnDemandUrl Option

The `TLSOnDemandUrl` option can be set to either:

- **An external URL** (e.g., `https://my-allow-service/allow-host`):
- The service will make an HTTP request to this external URL to determine if a certificate should be issued for a given host.

- **A local path** (e.g., `/allow-host`):
- The service will internally route a request to this path using its own load balancer and handler. You must ensure your service responds to this path appropriately.

### Example: External URL
```yaml
TLSOnDemandUrl: "https://my-allow-service/allow-host"
```

### Example: Local Path
```yaml
TLSOnDemandUrl: "/allow-host"
```
Comment on lines +178 to +188
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The documentation uses YAML syntax in the examples (lines 184-191), but this appears to be a CLI tool that uses command-line flags, not YAML configuration. The examples should use the CLI flag format (--tls-on-demand-url) as shown in line 142, not YAML format, to avoid confusing users about how to configure this option.

Copilot uses AI. Check for mistakes.

When using a local path, your service should implement a handler for the specified path (e.g., `/allow-host`) that returns `200 OK` to allow certificate issuance, or another status code to deny it.


Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

The documentation section "TLSOnDemandUrl Option" appears to be duplicating information already covered in the "On-demand TLS" section above (lines 137-162). This creates redundancy and potential maintenance issues. Consider consolidating this information into the existing "On-demand TLS" section or removing this duplicate section.

Suggested change
## TLSOnDemandUrl Option
The `TLSOnDemandUrl` option can be set to either:
- **An external URL** (e.g., `https://my-allow-service/allow-host`):
- The service will make an HTTP request to this external URL to determine if a certificate should be issued for a given host.
- **A local path** (e.g., `/allow-host`):
- The service will internally route a request to this path using its own load balancer and handler. You must ensure your service responds to this path appropriately.
### Example: External URL
```yaml
TLSOnDemandUrl: "https://my-allow-service/allow-host"
```
### Example: Local Path
```yaml
TLSOnDemandUrl: "/allow-host"
```
When using a local path, your service should implement a handler for the specified path (e.g., `/allow-host`) that returns `200 OK` to allow certificate issuance, or another status code to deny it.

Copilot uses AI. Check for mistakes.
## Specifying `run` options with environment variables

In some environments, like when running a Docker container, it can be convenient
Expand Down
6 changes: 6 additions & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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.")
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

The --tls flag help text says it "requires a non-empty host", but when --tls-on-demand-url is provided the code explicitly switches to wildcard hosts. Please update the flag description (and/or clarify how --host should be used with on-demand TLS) so CLI help matches the actual behavior.

Suggested change
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.")
deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSEnabled, "tls", false, "Configure TLS for this target. Without --tls-on-demand-url, at least one host is typically required; with --tls-on-demand-url, hosts may be left empty to allow wildcard/on-demand hosts.")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSOnDemandUrl, "tls-on-demand-url", "", "Make an HTTP request to the given URL to decide whether a host is allowed to have a certificate issued (may be used with empty --host for wildcard/on-demand hosts).")

Copilot uses AI. Check for mistakes.
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 Expand Up @@ -101,6 +102,11 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error {
}

if c.args.ServiceOptions.TLSEnabled {
if c.args.ServiceOptions.TLSOnDemandUrl != "" {
c.args.ServiceOptions.Hosts = []string{""}
return nil
}

if len(c.args.ServiceOptions.Hosts) == 0 {
return fmt.Errorf("host must be set when using TLS")
}
Comment on lines +110 to +116
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

preRun calls ServiceOptions.Normalize() before validating TLS host requirements. Since NormalizeHosts converts an empty host list into []string{""}, the later check len(c.args.ServiceOptions.Hosts) == 0 will never catch “no host provided”, and --tls can slip through with an empty host when --tls-on-demand-url is not set. Consider validating that at least one non-empty host is present (e.g., Hosts[0] != "") when TLS is enabled and on-demand URL is empty, and avoid the early return nil so the rest of preRun validations continue to run.

Suggested change
c.args.ServiceOptions.Hosts = []string{""}
return nil
}
if len(c.args.ServiceOptions.Hosts) == 0 {
return fmt.Errorf("host must be set when using TLS")
}
// When using TLS on demand, use an empty host sentinel and allow
// the rest of the validations below to run.
c.args.ServiceOptions.Hosts = []string{""}
} else {
// When TLS is enabled without on-demand URL, require at least one
// non-empty host. Normalize() may have converted an empty host
// list into []string{""}, so we must check for a non-empty value.
if len(c.args.ServiceOptions.Hosts) == 0 || c.args.ServiceOptions.Hosts[0] == "" {
return fmt.Errorf("host must be set when using TLS")
}
}

Copilot uses AI. Check for mistakes.
Expand Down
37 changes: 37 additions & 0 deletions internal/cmd/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,40 @@ func TestDeployCommand_CanonicalHostValidation(t *testing.T) {
})
}
}

func TestDeployCommand_preRun_TLSOnDemandUrl(t *testing.T) {
t.Run("TLS enabled with TLS on-demand URL should set hosts to empty string", func(t *testing.T) {
deployCmd := newDeployCommand()

// Set flags for TLS with on-demand URL
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("tls-on-demand-url", "http://example.com/validate")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/")

// Call preRun
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.NoError(t, err)

// Verify that hosts is set to empty string
assert.Equal(t, []string{""}, deployCmd.args.ServiceOptions.Hosts)
})

t.Run("TLS enabled without TLS on-demand URL should not modify hosts", func(t *testing.T) {
deployCmd := newDeployCommand()

// Set flags for TLS without on-demand URL
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/")

// Call preRun
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.NoError(t, err)

// Verify that hosts is not modified
assert.Equal(t, []string{"example.com"}, deployCmd.args.ServiceOptions.Hosts)
})
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

There’s no coverage for the expected validation behavior when --tls-on-demand-url is set but TLS is configured on a non-root path prefix (which should be rejected, consistent with the existing TLS root-path rule). Adding a test case here will prevent regressions once preRun enforces the root-path requirement for on-demand TLS too.

Suggested change
})
})
t.Run("TLS on-demand URL with non-root path prefix should return error", func(t *testing.T) {
deployCmd := newDeployCommand()
// Set flags for TLS with on-demand URL and non-root path prefix
deployCmd.cmd.Flags().Set("target", "http://localhost:8080")
deployCmd.cmd.Flags().Set("tls", "true")
deployCmd.cmd.Flags().Set("tls-on-demand-url", "http://example.com/validate")
deployCmd.cmd.Flags().Set("host", "example.com")
deployCmd.cmd.Flags().Set("path-prefix", "/api")
// Call preRun and expect an error due to non-root path prefix
err := deployCmd.preRun(deployCmd.cmd, []string{"test-service"})
require.Error(t, err)
})

Copilot uses AI. Check for mistakes.
}
185 changes: 156 additions & 29 deletions internal/server/router_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package server

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

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

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -211,45 +215,45 @@ func TestRouter_UpdatingOptions(t *testing.T) {
}

func TestRouter_CanonicalHostRedirect(t *testing.T) {
router := testRouter(t)
_, target := testBackend(t, "first", http.StatusOK)
router := testRouter(t)
_, target := testBackend(t, "first", http.StatusOK)

serviceOptions := defaultServiceOptions
serviceOptions.Hosts = []string{"example.com", "www.example.com"}
serviceOptions.CanonicalHost = "example.com"
serviceOptions := defaultServiceOptions
serviceOptions.Hosts = []string{"example.com", "www.example.com"}
serviceOptions.CanonicalHost = "example.com"

require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions))
require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions))

statusCode, _ := sendGETRequest(router, "http://www.example.com/")
assert.Equal(t, http.StatusMovedPermanently, statusCode)
statusCode, _ := sendGETRequest(router, "http://www.example.com/")
assert.Equal(t, http.StatusMovedPermanently, statusCode)

statusCode, body := sendGETRequest(router, "http://example.com/")
assert.Equal(t, http.StatusOK, statusCode)
assert.Equal(t, "first", body)
statusCode, body := sendGETRequest(router, "http://example.com/")
assert.Equal(t, http.StatusOK, statusCode)
assert.Equal(t, "first", body)
}

func TestRouter_CanonicalHostRedirectWithTLS(t *testing.T) {
router := testRouter(t)
_, target := testBackend(t, "first", http.StatusOK)
router := testRouter(t)
_, target := testBackend(t, "first", http.StatusOK)

serviceOptions := defaultServiceOptions
serviceOptions.Hosts = []string{"example.com", "www.example.com"}
serviceOptions.CanonicalHost = "example.com"
serviceOptions.TLSEnabled = true
serviceOptions.TLSRedirect = true
serviceOptions := defaultServiceOptions
serviceOptions.Hosts = []string{"example.com", "www.example.com"}
serviceOptions.CanonicalHost = "example.com"
serviceOptions.TLSEnabled = true
serviceOptions.TLSRedirect = true

require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions))
require.NoError(t, router.DeployService("service1", []string{target}, defaultEmptyReaders, serviceOptions, defaultTargetOptions, defaultDeploymentOptions))

// Should go directly to https://example.com in a single redirect
statusCode, _ := sendGETRequest(router, "http://www.example.com/")
assert.Equal(t, http.StatusMovedPermanently, statusCode)
// Should go directly to https://example.com in a single redirect
statusCode, _ := sendGETRequest(router, "http://www.example.com/")
assert.Equal(t, http.StatusMovedPermanently, statusCode)

// HTTPS request to non-canonical host should redirect to canonical host but remain HTTPS
req := httptest.NewRequest(http.MethodGet, "https://www.example.com/", nil)
req.TLS = &tls.ConnectionState{}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusMovedPermanently, w.Result().StatusCode)
// HTTPS request to non-canonical host should redirect to canonical host but remain HTTPS
req := httptest.NewRequest(http.MethodGet, "https://www.example.com/", nil)
req.TLS = &tls.ConnectionState{}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusMovedPermanently, w.Result().StatusCode)
}

func TestRouter_DeploymentsWithErrorsDoNotUpdateService(t *testing.T) {
Expand Down Expand Up @@ -771,6 +775,129 @@ func TestRouter_RestoreLastSavedState(t *testing.T) {
assert.Equal(t, "third", body)
}

func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyUsesOnDemandChecker(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.json")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

state := fmt.Sprintf(`[{
"name":"ondemand",
"options":{
"hosts":[""],
"path_prefixes":["/"],
"tls_enabled":true,
"tls_certificate_path":"",
"tls_private_key_path":"",
"tls_on_demand_url":"%s",
"tls_redirect":false,
"canonical_host":"",
"acme_directory":"",
"acme_cache_path":"",
"error_page_path":"",
"strip_prefix":true,
"writer_affinity_timeout":1000000000,
"read_targets_accept_websockets":false
},
"target_options":{
"health_check_config":{"path":"/up","port":0,"interval":1000000000,"timeout":5000000000,"host":""},
"response_timeout":30000000000,
"buffer_requests":false,
"buffer_responses":false,
"max_memory_buffer_size":1048576,
"max_request_body_size":0,
"max_response_body_size":0,
"log_request_headers":null,
"log_response_headers":null,
"forward_headers":false,
"scope_cookie_paths":false
},
"active_targets":["localhost:3000"],
"active_readers":[],
"rollout_targets":null,
"rollout_readers":null,
"pause_controller":{"state":0,"stop_message":"","fail_after":0},
"rollout_controller":null
}]`, server.URL)
require.NoError(t, os.WriteFile(statePath, []byte(state), 0600))

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(), "tenant.example.com"))
}

func TestRouter_RestoreLastSavedState_WithTLSOnDemandURL_HostPolicyDeniesWhenCheckerRejects(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state-deny.json")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("host") == "tenant.example.com" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusForbidden)
}))
defer server.Close()

state := fmt.Sprintf(`[{
"name":"ondemand-deny",
"options":{
"hosts":[""],
"path_prefixes":["/"],
"tls_enabled":true,
"tls_certificate_path":"",
"tls_private_key_path":"",
"tls_on_demand_url":"%s",
"tls_redirect":false,
"canonical_host":"",
"acme_directory":"",
"acme_cache_path":"",
"error_page_path":"",
"strip_prefix":true,
"writer_affinity_timeout":1000000000,
"read_targets_accept_websockets":false
},
"target_options":{
"health_check_config":{"path":"/up","port":0,"interval":1000000000,"timeout":5000000000,"host":""},
"response_timeout":30000000000,
"buffer_requests":false,
"buffer_responses":false,
"max_memory_buffer_size":1048576,
"max_request_body_size":0,
"max_response_body_size":0,
"log_request_headers":null,
"log_response_headers":null,
"forward_headers":false,
"scope_cookie_paths":false
},
"active_targets":["localhost:3000"],
"active_readers":[],
"rollout_targets":null,
"rollout_readers":null,
"pause_controller":{"state":0,"stop_message":"","fail_after":0},
"rollout_controller":null
}]`, server.URL)
require.NoError(t, os.WriteFile(statePath, []byte(state), 0600))

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

service := router.services.Get("ondemand-deny")
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(), "tenant.example.com"))
assert.Error(t, manager.HostPolicy(context.Background(), "denied.example.com"))
}

// Helpers

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