Skip to content
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

feat: HTTP/HTTPS flexible mode #1

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,29 @@ applications. To enable this, add the `--tls` flag when deploying an instance:

kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls

### On-demand TLS

In addition of the automatic TLS functionality, Kamal Proxy can also dynamically obtain a TLS certificate
from 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 the startup.

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

The On-demand URL endpoint will have to answer a 200 HTTP status code.
Kamal Proxy will call the on-demand URL with a query string of `?host=` containing the host received by Kamal Proxy.

It also must respond as fast as possible, a couple of milliseconds top.

### TLS Flexible mode

The On-demand TLS feature offers a TLS certificate for any dynamic host.
However, some hosts can be served by Cloudflare, and in that case, Kamal Proxy is unable to generate a TLS certificate.

Unless you provide a custom TLS certificate for those hosts, a quick solution is to allow a non-secure connection between Cloudflare and Kamal Proxy ("flexible" mode in Cloudflare). On the Kamal Proxy side, we need to accept non-secure connections and not redirect them to HTTPS.

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

In return, the application handling the requests from Kamal Proxy must be in charge of redirecting HTTP connections to HTTPS.

### Custom TLS certificate

Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.Hosts, "host", []string{}, "Host(s) to serve this target on (empty for wildcard)")

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.TLSFlexibleMode, "tls-flexible-mode", false, "Allow Kamal proxy to be flexible (accept both HTTP and HTTPS traffic)")
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
49 changes: 47 additions & 2 deletions internal/server/service.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package server

import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
Expand Down Expand Up @@ -66,8 +69,10 @@ type HealthCheckConfig struct {

type ServiceOptions struct {
TLSEnabled bool `json:"tls_enabled"`
TLSOnDemandUrl string `json:"tls_on_demand_url"`
TLSCertificatePath string `json:"tls_certificate_path"`
TLSPrivateKeyPath string `json:"tls_private_key_path"`
TLSFlexibleMode bool `json:"tls_flexible_mode"`
ACMEDirectory string `json:"acme_directory"`
ACMECachePath string `json:"acme_cache_path"`
ErrorPagePath string `json:"error_page_path"`
Expand Down Expand Up @@ -318,14 +323,54 @@ func (s *Service) createCertManager(hosts []string, options ServiceOptions) (Cer
}
}

hostPolicy, err := s.createAutoCertHostPolicy(hosts, options)

if err != nil {
return nil, err
}

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

func (s *Service) createAutoCertHostPolicy(hosts []string, options ServiceOptions) (autocert.HostPolicy, error) {
slog.Info("createAutoCertHostPolicy called", options.TLSOnDemandUrl, len(hosts), "🚨", "ok")

if options.TLSOnDemandUrl == "" {
return autocert.HostWhitelist(hosts...), nil
}

_, err := url.ParseRequestURI(options.TLSOnDemandUrl)

if err != nil {
slog.Error("Unable to parse the tls_on_demand_url URL")
return nil, err
}

slog.Info("Will use the tls_on_demand_url URL")

return func(ctx context.Context, host string) error {
slog.Info("Get a certificate for", host, "🤞")

resp, err := http.Get(fmt.Sprintf("%s?host=%s", options.TLSOnDemandUrl, url.QueryEscape(host)))

if err != nil {
slog.Error("Unable to reach the TLS on demand URL", host, err)
return err
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s is not allowed to get a certificate", host)
}

return nil
}, nil
}

func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) {
var err error
var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget)
Expand All @@ -351,7 +396,7 @@ func (s *Service) createMiddleware(options ServiceOptions, certManager CertManag
func (s *Service) serviceRequestWithTarget(w http.ResponseWriter, r *http.Request) {
LoggingRequestContext(r).Service = s.name

if s.options.TLSEnabled && r.TLS == nil {
if s.options.TLSEnabled && s.options.TLSFlexibleMode == false && r.TLS == nil {
s.redirectToHTTPS(w, r)
return
}
Expand Down