From 9e72185da015398a16fb97e80bca55c8de5b9c12 Mon Sep 17 00:00:00 2001 From: Ben Simmons Date: Tue, 11 Aug 2026 10:10:19 -0500 Subject: [PATCH 1/3] Allow serving an app on multiple hostnames Apps could only be registered with the proxy under a single hostname, so common aliases like www were unroutable and got no TLS certificate, even though kamal-proxy supports multiple hosts per service. The --host flag can now be repeated. Hostnames are stored as a comma-separated list in the existing host settings field, keeping serialized settings backward compatible with single-host installs. The first hostname is canonical: it is used for display URLs and post-deploy HTTP verification. Host lookups and conflict checks match any of an app's hostnames, so `once update www.example.com` works too. --- internal/command/deploy.go | 17 +++++++------ internal/command/settings_flags.go | 6 ++--- internal/command/update.go | 6 +++-- internal/docker/application.go | 4 ++-- internal/docker/application_settings.go | 25 +++++++++++++++++++- internal/docker/application_settings_test.go | 22 +++++++++++++++++ internal/docker/namespace.go | 10 ++++---- internal/docker/namespace_test.go | 18 ++++++++++++++ internal/docker/proxy.go | 6 ++--- internal/docker/proxy_test.go | 20 ++++++++++++++-- internal/ui/settings.go | 8 ++++--- 11 files changed, 115 insertions(+), 27 deletions(-) diff --git a/internal/command/deploy.go b/internal/command/deploy.go index ec90559..5916028 100644 --- a/internal/command/deploy.go +++ b/internal/command/deploy.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "github.com/spf13/cobra" @@ -38,16 +39,18 @@ func (d *deployCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr return fmt.Errorf("%w: %w", docker.ErrSetupFailed, err) } - host := d.flags.host - if host == "" { - host = docker.NameFromImageRef(imageRef) + ".localhost" + hosts := d.flags.host + if len(hosts) == 0 { + hosts = []string{docker.NameFromImageRef(imageRef) + ".localhost"} } - if ns.HostInUse(host) { - return docker.ErrHostnameInUse + for _, host := range hosts { + if ns.HostInUse(host) { + return docker.ErrHostnameInUse + } } - settings, err := d.flags.buildSettings(imageRef, host) + settings, err := d.flags.buildSettings(imageRef, strings.Join(hosts, ",")) if err != nil { return err } @@ -61,7 +64,7 @@ func (d *deployCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr app := docker.NewApplication(ns, settings) - return runWithProgress("Deploying "+host, func(progress docker.DeployProgressCallback) error { + return runWithProgress("Deploying "+hosts[0], func(progress docker.DeployProgressCallback) error { if err := app.Deploy(ctx, progress); err != nil { if cleanupErr := app.Destroy(context.Background(), true); cleanupErr != nil { slog.Error("Failed to clean up after deploy failure", "app", name, "error", cleanupErr) diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index 2ec77a4..8717517 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -11,7 +11,7 @@ import ( ) type settingsFlags struct { - host string + host []string disableTLS bool env []string smtpServer string @@ -27,7 +27,7 @@ type settingsFlags struct { } func (f *settingsFlags) register(cmd *cobra.Command) { - cmd.Flags().StringVar(&f.host, "host", "", "hostname for the application") + cmd.Flags().StringArrayVar(&f.host, "host", nil, "hostname for the application (can be repeated to serve additional hostnames)") cmd.Flags().BoolVar(&f.disableTLS, "disable-tls", false, "disable TLS for this application") cmd.Flags().StringArrayVar(&f.env, "env", nil, "environment variable in KEY=VALUE format (can be repeated)") cmd.Flags().StringVar(&f.smtpServer, "smtp-server", "", "SMTP server address") @@ -88,7 +88,7 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica s.Image = image if cmd.Flags().Changed("host") { - s.Host = f.host + s.Host = strings.Join(f.host, ",") } if cmd.Flags().Changed("disable-tls") { s.DisableTLS = f.disableTLS diff --git a/internal/command/update.go b/internal/command/update.go index 2b5ccb7..fc94782 100644 --- a/internal/command/update.go +++ b/internal/command/update.go @@ -55,8 +55,10 @@ func (u *updateCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr } if settings.Host != app.Settings.Host { - if ns.HostInUseByAnother(settings.Host, app.Settings.Name) { - return docker.ErrHostnameInUse + for _, host := range settings.Hosts() { + if ns.HostInUseByAnother(host, app.Settings.Name) { + return docker.ErrHostnameInUse + } } } diff --git a/internal/docker/application.go b/internal/docker/application.go index 351d0f5..67f6aed 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -108,7 +108,7 @@ func (a *Application) URL() string { defaultPort = 443 } - base := scheme + "://" + a.Settings.Host + base := scheme + "://" + a.Settings.PrimaryHost() if a.namespace == nil { return base @@ -380,7 +380,7 @@ func (a *Application) deployWithVolume(ctx context.Context, vol *ApplicationVolu if err := a.namespace.Proxy().Deploy(ctx, DeployOptions{ AppName: a.Settings.Name, Target: shortContainerID, - Host: a.Settings.Host, + Hosts: a.Settings.Hosts(), TLS: a.Settings.TLSEnabled(), }); err != nil { a.namespace.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}) diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 613b525..9928034 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -109,6 +109,29 @@ func (s ApplicationSettings) Marshal() string { return string(b) } +// Host stores one or more hostnames as a comma-separated list, keeping the +// serialized settings backward compatible with single-host installs. +func (s ApplicationSettings) Hosts() []string { + if s.Host == "" { + return nil + } + + var hosts []string + for _, h := range strings.Split(s.Host, ",") { + if h = strings.TrimSpace(h); h != "" { + hosts = append(hosts, h) + } + } + return hosts +} + +func (s ApplicationSettings) PrimaryHost() string { + if hosts := s.Hosts(); len(hosts) > 0 { + return hosts[0] + } + return "" +} + func (s ApplicationSettings) Validate() error { if s.Image == "" { return ErrImageRequired @@ -120,7 +143,7 @@ func (s ApplicationSettings) Validate() error { } func (s ApplicationSettings) TLSEnabled() bool { - return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.Host) + return s.Host != "" && !s.DisableTLS && !IsLocalhost(s.PrimaryHost()) } func (s ApplicationSettings) Equal(other ApplicationSettings) bool { diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 5d51f5d..2f8c8c4 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -338,3 +338,25 @@ func TestAutoUpdateAndBackupMarshalRoundTrip(t *testing.T) { assert.True(t, restored.Backup.AutoBackup) assert.True(t, original.Equal(restored)) } + +func TestHosts(t *testing.T) { + assert.Nil(t, ApplicationSettings{}.Hosts()) + assert.Equal(t, []string{"app.example.com"}, ApplicationSettings{Host: "app.example.com"}.Hosts()) + assert.Equal(t, + []string{"app.example.com", "www.app.example.com"}, + ApplicationSettings{Host: "app.example.com,www.app.example.com"}.Hosts()) + assert.Equal(t, + []string{"app.example.com", "www.app.example.com"}, + ApplicationSettings{Host: " app.example.com , www.app.example.com ,"}.Hosts()) +} + +func TestPrimaryHost(t *testing.T) { + assert.Equal(t, "", ApplicationSettings{}.PrimaryHost()) + assert.Equal(t, "app.example.com", ApplicationSettings{Host: "app.example.com"}.PrimaryHost()) + assert.Equal(t, "app.example.com", ApplicationSettings{Host: "app.example.com,www.app.example.com"}.PrimaryHost()) +} + +func TestTLSEnabledWithMultipleHosts(t *testing.T) { + assert.True(t, ApplicationSettings{Host: "app.example.com,www.app.example.com"}.TLSEnabled()) + assert.False(t, ApplicationSettings{Host: "app.localhost,www.app.example.com"}.TLSEnabled()) +} diff --git a/internal/docker/namespace.go b/internal/docker/namespace.go index ea4182a..ab7e902 100644 --- a/internal/docker/namespace.go +++ b/internal/docker/namespace.go @@ -108,7 +108,7 @@ func (n *Namespace) Applications() []*Application { func (n *Namespace) ApplicationByHost(host string) *Application { for _, app := range n.applications { - if app.Settings.Host == host { + if slices.Contains(app.Settings.Hosts(), host) { return app } } @@ -121,7 +121,7 @@ func (n *Namespace) HostInUse(host string) bool { func (n *Namespace) HostInUseByAnother(host string, excludeApp string) bool { for _, app := range n.applications { - if app.Settings.Host == host && app.Settings.Name != excludeApp { + if slices.Contains(app.Settings.Hosts(), host) && app.Settings.Name != excludeApp { return true } } @@ -233,8 +233,10 @@ func (n *Namespace) Restore(ctx context.Context, r io.ReadSeeker) (*Application, return nil, fmt.Errorf("parsing backup: %w", err) } - if n.HostInUse(appSettings.Host) { - return nil, ErrHostnameInUse + for _, host := range appSettings.Hosts() { + if n.HostInUse(host) { + return nil, ErrHostnameInUse + } } name, err := n.UniqueName(NameFromImageRef(appSettings.Image)) diff --git a/internal/docker/namespace_test.go b/internal/docker/namespace_test.go index 328fec3..8425169 100644 --- a/internal/docker/namespace_test.go +++ b/internal/docker/namespace_test.go @@ -50,6 +50,24 @@ func TestApplicationByHost(t *testing.T) { assert.Nil(t, ns.ApplicationByHost("missing.localhost")) } +func TestApplicationByHostMatchesAnyHost(t *testing.T) { + ns := &Namespace{name: "test"} + ns.applications = append(ns.applications, + NewApplication(ns, ApplicationSettings{Name: "app1", Host: "app1.example.com,www.app1.example.com"}), + ) + + for _, host := range []string{"app1.example.com", "www.app1.example.com"} { + app := ns.ApplicationByHost(host) + require.NotNil(t, app) + assert.Equal(t, "app1", app.Settings.Name) + } + + assert.Nil(t, ns.ApplicationByHost("app1.example.com,www.app1.example.com")) + assert.True(t, ns.HostInUse("www.app1.example.com")) + assert.True(t, ns.HostInUseByAnother("www.app1.example.com", "app2")) + assert.False(t, ns.HostInUseByAnother("www.app1.example.com", "app1")) +} + func TestHostInUse(t *testing.T) { ns := &Namespace{name: "test"} ns.applications = append(ns.applications, diff --git a/internal/docker/proxy.go b/internal/docker/proxy.go index 4e43216..171c556 100644 --- a/internal/docker/proxy.go +++ b/internal/docker/proxy.go @@ -56,7 +56,7 @@ func (s ProxySettings) Marshal() string { type DeployOptions struct { AppName string Target string - Host string + Hosts []string TLS bool } @@ -218,8 +218,8 @@ func (p *Proxy) ensureRunning(ctx context.Context, info container.InspectRespons func (p *Proxy) deployArgs(opts DeployOptions) []string { args := []string{"kamal-proxy", "deploy", opts.AppName, "--target", opts.Target, "--deploy-timeout", deployTimeout} - if opts.Host != "" { - args = append(args, "--host", opts.Host) + for _, host := range opts.Hosts { + args = append(args, "--host", host) } if opts.TLS { diff --git a/internal/docker/proxy_test.go b/internal/docker/proxy_test.go index eac246b..f586ca7 100644 --- a/internal/docker/proxy_test.go +++ b/internal/docker/proxy_test.go @@ -30,12 +30,28 @@ func TestDeployArgs(t *testing.T) { }) t.Run("with host", func(t *testing.T) { - args := proxy.deployArgs(DeployOptions{AppName: "chat", Target: "localhost:3000", Host: "chat.example.com"}) + args := proxy.deployArgs(DeployOptions{AppName: "chat", Target: "localhost:3000", Hosts: []string{"chat.example.com"}}) assert.Contains(t, args, "--host") assert.Contains(t, args, "chat.example.com") }) + t.Run("with multiple hosts", func(t *testing.T) { + args := proxy.deployArgs(DeployOptions{ + AppName: "chat", + Target: "localhost:3000", + Hosts: []string{"chat.example.com", "www.chat.example.com"}, + }) + + assert.Equal(t, []string{ + "kamal-proxy", "deploy", "chat", + "--target", "localhost:3000", + "--deploy-timeout", "120s", + "--host", "chat.example.com", + "--host", "www.chat.example.com", + }, args) + }) + t.Run("with TLS", func(t *testing.T) { args := proxy.deployArgs(DeployOptions{AppName: "chat", Target: "localhost:3000", TLS: true}) @@ -46,7 +62,7 @@ func TestDeployArgs(t *testing.T) { args := proxy.deployArgs(DeployOptions{ AppName: "chat", Target: "localhost:3000", - Host: "chat.example.com", + Hosts: []string{"chat.example.com"}, TLS: true, }) diff --git a/internal/ui/settings.go b/internal/ui/settings.go index c9906f7..93f69b4 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -243,9 +243,11 @@ func (m Settings) handleFormSubmit(msg SettingsSectionSubmitMsg) (Component, tea if msg.Settings.Equal(m.app.Settings) { return m, m.navigateToDashboard() } - if m.namespace.HostInUseByAnother(msg.Settings.Host, m.app.Settings.Name) { - m.err = docker.ErrHostnameInUse - return m, nil + for _, host := range msg.Settings.Hosts() { + if m.namespace.HostInUseByAnother(host, m.app.Settings.Name) { + m.err = docker.ErrHostnameInUse + return m, nil + } } m.state = settingsStateDeploying m.app.Settings = msg.Settings From 6e92319dcd9b455ad91a63f70e1beb67bd35e1be Mon Sep 17 00:00:00 2001 From: Ben Simmons Date: Tue, 11 Aug 2026 11:43:44 -0500 Subject: [PATCH 2/3] Address review: normalize hosts before duplicate check, reject mixed localhost lists Duplicate detection ran on the raw flag values while deployment used the parsed list, so `--host ' example.com '` could slip past the check and then register a conflicting normalized hostname with the proxy. Build the settings first and check the parsed hosts. TLS is a single proxy-wide switch covering every hostname an app serves, so deriving it from the primary host alone made mixed lists order-dependent: `example.com,app.localhost` would ask ACME for a `.localhost` certificate, while the reverse order silently disabled TLS. Reject mixed localhost/public lists in Validate, and run that validation on TUI submits, which previously bypassed it. --- internal/command/deploy.go | 14 +++++++------- internal/docker/application.go | 1 + internal/docker/application_settings.go | 8 ++++++++ internal/docker/application_settings_test.go | 19 +++++++++++++++++++ internal/ui/settings.go | 4 ++++ 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/internal/command/deploy.go b/internal/command/deploy.go index 5916028..ff6b44d 100644 --- a/internal/command/deploy.go +++ b/internal/command/deploy.go @@ -44,17 +44,17 @@ func (d *deployCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr hosts = []string{docker.NameFromImageRef(imageRef) + ".localhost"} } - for _, host := range hosts { - if ns.HostInUse(host) { - return docker.ErrHostnameInUse - } - } - settings, err := d.flags.buildSettings(imageRef, strings.Join(hosts, ",")) if err != nil { return err } + for _, host := range settings.Hosts() { + if ns.HostInUse(host) { + return docker.ErrHostnameInUse + } + } + baseName := docker.NameFromImageRef(imageRef) name, err := ns.UniqueName(baseName) if err != nil { @@ -64,7 +64,7 @@ func (d *deployCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr app := docker.NewApplication(ns, settings) - return runWithProgress("Deploying "+hosts[0], func(progress docker.DeployProgressCallback) error { + return runWithProgress("Deploying "+settings.PrimaryHost(), func(progress docker.DeployProgressCallback) error { if err := app.Deploy(ctx, progress); err != nil { if cleanupErr := app.Destroy(context.Background(), true); cleanupErr != nil { slog.Error("Failed to clean up after deploy failure", "app", name, "error", cleanupErr) diff --git a/internal/docker/application.go b/internal/docker/application.go index 67f6aed..0428442 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -28,6 +28,7 @@ var ( ErrApplicationNotRunning = errors.New("the application is not running") ErrBackupPathRelative = errors.New("backup path must be absolute") ErrAutoBackupWithoutPath = errors.New("auto-backup requires a backup path") + ErrMixedLocalhostHosts = errors.New("hosts must be all localhost or all public: TLS applies to every hostname an app serves") ErrSetupFailed = errors.New("setup failed") ErrPullFailed = &describedError{ msg: "pull failed", diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 9928034..51b7d9d 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -139,6 +139,14 @@ func (s ApplicationSettings) Validate() error { if s.Backup.AutoBackup && s.Backup.Path == "" { return ErrAutoBackupWithoutPath } + // TLS is a single proxy-wide switch covering every hostname the app + // serves, so localhost and public hostnames cannot be mixed. + hosts := s.Hosts() + for _, host := range hosts { + if IsLocalhost(host) != IsLocalhost(hosts[0]) { + return ErrMixedLocalhostHosts + } + } return nil } diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 2f8c8c4..5b0e41b 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -360,3 +360,22 @@ func TestTLSEnabledWithMultipleHosts(t *testing.T) { assert.True(t, ApplicationSettings{Host: "app.example.com,www.app.example.com"}.TLSEnabled()) assert.False(t, ApplicationSettings{Host: "app.localhost,www.app.example.com"}.TLSEnabled()) } + +func TestValidateRejectsMixedLocalhostHosts(t *testing.T) { + valid := []string{ + "app.example.com", + "app.example.com,www.app.example.com", + "app.localhost,alias.localhost", + } + for _, host := range valid { + assert.NoError(t, ApplicationSettings{Image: "img:latest", Host: host}.Validate(), host) + } + + mixed := []string{ + "app.example.com,app.localhost", + "app.localhost,app.example.com", + } + for _, host := range mixed { + assert.ErrorIs(t, ApplicationSettings{Image: "img:latest", Host: host}.Validate(), ErrMixedLocalhostHosts, host) + } +} diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 93f69b4..eeac89f 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -243,6 +243,10 @@ func (m Settings) handleFormSubmit(msg SettingsSectionSubmitMsg) (Component, tea if msg.Settings.Equal(m.app.Settings) { return m, m.navigateToDashboard() } + if err := msg.Settings.Validate(); err != nil { + m.err = err + return m, nil + } for _, host := range msg.Settings.Hosts() { if m.namespace.HostInUseByAnother(host, m.app.Settings.Name) { m.err = docker.ErrHostnameInUse From 0bc9480cec49cd79a221a2847f0460421184e01f Mon Sep 17 00:00:00 2001 From: Ben Simmons Date: Tue, 11 Aug 2026 12:09:33 -0500 Subject: [PATCH 3/3] Add --canonical-host to redirect aliases to one hostname Serving the same app on several hostnames leaves every alias returning 200, which is usually not what you want for a www alias: it splits search engine ranking across two URLs. kamal-proxy already supports redirecting to a canonical hostname, so expose it. once deploy img --host example.com --host www.example.com \ --canonical-host example.com The canonical host must be one of the app's hostnames, otherwise visitors are redirected to a hostname the proxy does not route. Display URLs and post-deploy verification use the canonical host when one is set. Also fixes the TUI's TLS field, which tested the raw hostname value for localhost and so failed to detect it once that value could hold a list. --- internal/command/settings_flags.go | 40 +++++++++++-------- internal/docker/application.go | 34 ++++++++-------- internal/docker/application_settings.go | 39 ++++++++++++++----- internal/docker/application_settings_test.go | 41 ++++++++++++++++++++ internal/docker/proxy.go | 13 +++++-- internal/docker/proxy_test.go | 22 +++++++++++ internal/ui/settings_form_application.go | 4 +- 7 files changed, 145 insertions(+), 48 deletions(-) diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index 8717517..f02e8b1 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -11,23 +11,25 @@ import ( ) type settingsFlags struct { - host []string - disableTLS bool - env []string - smtpServer string - smtpPort string - smtpUsername string - smtpPassword string - smtpFrom string - cpus int - memory int - autoUpdate bool - backupPath string - autoBackup bool + host []string + canonicalHost string + disableTLS bool + env []string + smtpServer string + smtpPort string + smtpUsername string + smtpPassword string + smtpFrom string + cpus int + memory int + autoUpdate bool + backupPath string + autoBackup bool } func (f *settingsFlags) register(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&f.host, "host", nil, "hostname for the application (can be repeated to serve additional hostnames)") + cmd.Flags().StringVar(&f.canonicalHost, "canonical-host", "", "redirect all requests to this hostname (must be one of --host)") cmd.Flags().BoolVar(&f.disableTLS, "disable-tls", false, "disable TLS for this application") cmd.Flags().StringArrayVar(&f.env, "env", nil, "environment variable in KEY=VALUE format (can be repeated)") cmd.Flags().StringVar(&f.smtpServer, "smtp-server", "", "SMTP server address") @@ -53,10 +55,11 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet } s := docker.ApplicationSettings{ - Image: image, - Host: host, - DisableTLS: f.disableTLS, - EnvVars: envVars, + Image: image, + Host: host, + CanonicalHost: f.canonicalHost, + DisableTLS: f.disableTLS, + EnvVars: envVars, SMTP: docker.SMTPSettings{ Server: f.smtpServer, Port: f.smtpPort, @@ -90,6 +93,9 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica if cmd.Flags().Changed("host") { s.Host = strings.Join(f.host, ",") } + if cmd.Flags().Changed("canonical-host") { + s.CanonicalHost = f.canonicalHost + } if cmd.Flags().Changed("disable-tls") { s.DisableTLS = f.disableTLS } diff --git a/internal/docker/application.go b/internal/docker/application.go index 0428442..f2ad1ff 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -20,17 +20,18 @@ import ( ) var ( - ErrApplicationExists = errors.New("application already exists") - ErrHostnameInUse = errors.New("hostname already in use") - ErrHostRequired = errors.New("host is required") - ErrInvalidBackup = errors.New("invalid backup archive") - ErrImageRequired = errors.New("image is required") - ErrApplicationNotRunning = errors.New("the application is not running") - ErrBackupPathRelative = errors.New("backup path must be absolute") - ErrAutoBackupWithoutPath = errors.New("auto-backup requires a backup path") - ErrMixedLocalhostHosts = errors.New("hosts must be all localhost or all public: TLS applies to every hostname an app serves") - ErrSetupFailed = errors.New("setup failed") - ErrPullFailed = &describedError{ + ErrApplicationExists = errors.New("application already exists") + ErrHostnameInUse = errors.New("hostname already in use") + ErrHostRequired = errors.New("host is required") + ErrInvalidBackup = errors.New("invalid backup archive") + ErrImageRequired = errors.New("image is required") + ErrApplicationNotRunning = errors.New("the application is not running") + ErrBackupPathRelative = errors.New("backup path must be absolute") + ErrAutoBackupWithoutPath = errors.New("auto-backup requires a backup path") + ErrMixedLocalhostHosts = errors.New("hosts must be all localhost or all public: TLS applies to every hostname an app serves") + ErrCanonicalHostNotServed = errors.New("canonical host must be one of the application's hostnames") + ErrSetupFailed = errors.New("setup failed") + ErrPullFailed = &describedError{ msg: "pull failed", description: "Failed to download the application image. Check that the image name is correct and try again.", } @@ -109,7 +110,7 @@ func (a *Application) URL() string { defaultPort = 443 } - base := scheme + "://" + a.Settings.PrimaryHost() + base := scheme + "://" + a.Settings.DisplayHost() if a.namespace == nil { return base @@ -379,10 +380,11 @@ func (a *Application) deployWithVolume(ctx context.Context, vol *ApplicationVolu shortContainerID := resp.ID[:12] if err := a.namespace.Proxy().Deploy(ctx, DeployOptions{ - AppName: a.Settings.Name, - Target: shortContainerID, - Hosts: a.Settings.Hosts(), - TLS: a.Settings.TLSEnabled(), + AppName: a.Settings.Name, + Target: shortContainerID, + Hosts: a.Settings.Hosts(), + CanonicalHost: a.Settings.CanonicalHost, + TLS: a.Settings.TLSEnabled(), }); err != nil { a.namespace.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true}) if strings.Contains(err.Error(), "target not healthy") || strings.Contains(err.Error(), "deploy timed out") { diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 51b7d9d..1dfc6ee 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "slices" "strconv" "strings" ) @@ -86,16 +87,17 @@ type BackupSettings struct { } type ApplicationSettings struct { - Name string `json:"name"` - Image string `json:"image"` - Host string `json:"host"` - DisableTLS bool `json:"disableTLS"` - EnvVars map[string]string `json:"env"` - SMTP SMTPSettings `json:"smtp"` - Resources ContainerResources `json:"resources"` - AutoUpdate bool `json:"autoUpdate"` - Backup BackupSettings `json:"backup"` - Keys Keys `json:"keys"` + Name string `json:"name"` + Image string `json:"image"` + Host string `json:"host"` + CanonicalHost string `json:"canonicalHost,omitempty"` + DisableTLS bool `json:"disableTLS"` + EnvVars map[string]string `json:"env"` + SMTP SMTPSettings `json:"smtp"` + Resources ContainerResources `json:"resources"` + AutoUpdate bool `json:"autoUpdate"` + Backup BackupSettings `json:"backup"` + Keys Keys `json:"keys"` } func UnmarshalApplicationSettings(s string) (ApplicationSettings, error) { @@ -132,6 +134,15 @@ func (s ApplicationSettings) PrimaryHost() string { return "" } +// DisplayHost is the hostname visitors end up on: the canonical host when the +// proxy is redirecting to one, otherwise the app's first hostname. +func (s ApplicationSettings) DisplayHost() string { + if s.CanonicalHost != "" { + return s.CanonicalHost + } + return s.PrimaryHost() +} + func (s ApplicationSettings) Validate() error { if s.Image == "" { return ErrImageRequired @@ -147,6 +158,11 @@ func (s ApplicationSettings) Validate() error { return ErrMixedLocalhostHosts } } + // Redirecting to a hostname the app doesn't serve would send visitors to + // a route the proxy knows nothing about. + if s.CanonicalHost != "" && !slices.Contains(hosts, s.CanonicalHost) { + return ErrCanonicalHostNotServed + } return nil } @@ -158,6 +174,9 @@ func (s ApplicationSettings) Equal(other ApplicationSettings) bool { if s.Name != other.Name || s.Image != other.Image || s.Host != other.Host || s.DisableTLS != other.DisableTLS { return false } + if s.CanonicalHost != other.CanonicalHost { + return false + } if s.Resources != other.Resources { return false } diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 5b0e41b..bfc3e77 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -379,3 +379,44 @@ func TestValidateRejectsMixedLocalhostHosts(t *testing.T) { assert.ErrorIs(t, ApplicationSettings{Image: "img:latest", Host: host}.Validate(), ErrMixedLocalhostHosts, host) } } + +func TestValidateCanonicalHost(t *testing.T) { + base := ApplicationSettings{Image: "img:latest", Host: "app.example.com,www.app.example.com"} + + for _, canonical := range []string{"", "app.example.com", "www.app.example.com"} { + s := base + s.CanonicalHost = canonical + assert.NoError(t, s.Validate(), canonical) + } + + s := base + s.CanonicalHost = "other.example.com" + assert.ErrorIs(t, s.Validate(), ErrCanonicalHostNotServed) +} + +func TestDisplayHost(t *testing.T) { + s := ApplicationSettings{Host: "app.example.com,www.app.example.com"} + assert.Equal(t, "app.example.com", s.DisplayHost()) + + s.CanonicalHost = "www.app.example.com" + assert.Equal(t, "www.app.example.com", s.DisplayHost()) + + assert.Equal(t, "", ApplicationSettings{}.DisplayHost()) +} + +func TestCanonicalHostMarshalRoundTrip(t *testing.T) { + original := ApplicationSettings{ + Name: "app", + Image: "img:latest", + Host: "app.example.com,www.app.example.com", + CanonicalHost: "app.example.com", + } + restored, err := UnmarshalApplicationSettings(original.Marshal()) + require.NoError(t, err) + assert.Equal(t, "app.example.com", restored.CanonicalHost) + assert.True(t, original.Equal(restored)) + + changed := original + changed.CanonicalHost = "www.app.example.com" + assert.False(t, original.Equal(changed)) +} diff --git a/internal/docker/proxy.go b/internal/docker/proxy.go index 171c556..bede64f 100644 --- a/internal/docker/proxy.go +++ b/internal/docker/proxy.go @@ -54,10 +54,11 @@ func (s ProxySettings) Marshal() string { } type DeployOptions struct { - AppName string - Target string - Hosts []string - TLS bool + AppName string + Target string + Hosts []string + CanonicalHost string + TLS bool } type Proxy struct { @@ -222,6 +223,10 @@ func (p *Proxy) deployArgs(opts DeployOptions) []string { args = append(args, "--host", host) } + if opts.CanonicalHost != "" { + args = append(args, "--canonical-host", opts.CanonicalHost) + } + if opts.TLS { args = append(args, "--tls") } diff --git a/internal/docker/proxy_test.go b/internal/docker/proxy_test.go index f586ca7..96a62d8 100644 --- a/internal/docker/proxy_test.go +++ b/internal/docker/proxy_test.go @@ -75,3 +75,25 @@ func TestDeployArgs(t *testing.T) { }, args) }) } + +func TestDeployArgsWithCanonicalHost(t *testing.T) { + proxy := &Proxy{} + + args := proxy.deployArgs(DeployOptions{ + AppName: "chat", + Target: "localhost:3000", + Hosts: []string{"chat.example.com", "www.chat.example.com"}, + CanonicalHost: "chat.example.com", + TLS: true, + }) + + assert.Equal(t, []string{ + "kamal-proxy", "deploy", "chat", + "--target", "localhost:3000", + "--deploy-timeout", "120s", + "--host", "chat.example.com", + "--host", "www.chat.example.com", + "--canonical-host", "chat.example.com", + "--tls", + }, args) +} diff --git a/internal/ui/settings_form_application.go b/internal/ui/settings_form_application.go index 8854977..6d0a750 100644 --- a/internal/ui/settings_form_application.go +++ b/internal/ui/settings_form_application.go @@ -25,7 +25,9 @@ func NewSettingsFormApplication(settings docker.ApplicationSettings) SettingsFor tlsField := NewCheckboxField("Enabled", !settings.DisableTLS) tlsField.SetDisabledWhen(func() (bool, string) { - if docker.IsLocalhost(hostnameField.Value()) { + // The field holds a comma-separated list, and Validate guarantees its + // hostnames are either all localhost or all public. + if docker.IsLocalhost(docker.ApplicationSettings{Host: hostnameField.Value()}.PrimaryHost()) { return true, "Not available for localhost" } return false, ""