-
Notifications
You must be signed in to change notification settings - Fork 73
implement the On-Demand TLS feature #63
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 19 commits
b807ef5
fbbcdd3
92398c0
d22e6a3
24ab71c
89ad217
80cd7f9
3247a51
25a7d09
08de3cd
4160a10
adf8772
30eadc4
6daf93a
7fa1b2d
65bef1e
a55cfa5
bb45ac3
78f89e8
d588d81
af47399
d0228bc
fec3323
3844ebd
0b33d56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| 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. | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
| ## 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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.") | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| 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).") |
did marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
Copilot
AI
Mar 4, 2026
There was a problem hiding this comment.
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.
| 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") | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
| }) | |
| }) | |
| 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) | |
| }) |
Uh oh!
There was an error while loading. Please reload this page.