-
Notifications
You must be signed in to change notification settings - Fork 82
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 all 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 |
|---|---|---|
| @@ -1,6 +1,5 @@ | ||
| # Kamal Proxy - A minimal HTTP proxy for zero-downtime deployments | ||
|
|
||
|
|
||
| ## What it does | ||
|
|
||
| Kamal Proxy is a tiny HTTP proxy, designed to make it easy to coordinate | ||
|
|
@@ -14,7 +13,6 @@ which provides a complete deployment experience including container packaging | |
| and provisioning. However, Kamal Proxy could also be used standalone or as part | ||
| of other deployment tooling. | ||
|
|
||
|
|
||
| ## A quick overview | ||
|
|
||
| To run an instance of the proxy, use the `kamal-proxy run` command. There's no | ||
|
|
@@ -98,7 +96,6 @@ Only one service at a time can route a specific host: | |
| kamal-proxy remove service1 | ||
| kamal-proxy deploy service2 --target web-2:3000 --host app1.example.com # succeeds | ||
|
|
||
|
|
||
| ### Path-based routing | ||
|
|
||
| For applications that split their traffic to different services based on the | ||
|
|
@@ -118,7 +115,6 @@ the original path (including the prefix), specify `--strip-path-prefix=false`: | |
|
|
||
| kamal-proxy deploy service1 --target web-1:3000 --path-prefix=/api --strip-path-prefix=false | ||
|
|
||
|
|
||
| ### Automatic TLS | ||
|
|
||
| Kamal Proxy can automatically obtain and renew TLS certificates for your | ||
|
|
@@ -133,6 +129,33 @@ Additionally, when using path-based routing, TLS options must be set on the | |
| 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 | ||
|
|
||
|
|
@@ -142,6 +165,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. | ||
|
|
||
| ## Specifying `run` options with environment variables | ||
|
|
||
|
|
@@ -163,7 +209,6 @@ example: | |
|
|
||
| KAMAL_PROXY_HTTP_PORT=8080 kamal-proxy run | ||
|
|
||
|
|
||
| ## Building | ||
|
|
||
| To build Kamal Proxy locally, if you have a working Go environment you can: | ||
|
|
@@ -174,7 +219,6 @@ Alternatively, build as a Docker container: | |
|
|
||
| make docker | ||
|
|
||
|
|
||
| ## Trying it out | ||
|
|
||
| See the [example](./example) folder for a Docker Compose setup that you can use | ||
|
|
||
| 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.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)") | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -101,13 +102,18 @@ func (c *deployCommand) preRun(cmd *cobra.Command, args []string) error { | |||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if c.args.ServiceOptions.TLSEnabled { | ||||||||||||||||||||||||||||||||||||||
| if len(c.args.ServiceOptions.Hosts) == 0 { | ||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("host must be set when using TLS") | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if !slices.Contains(c.args.ServiceOptions.PathPrefixes, "/") { | ||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("TLS settings must be specified on the root path service") | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| 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
|
||||||||||||||||||||||||||||||||||||||
| 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.