diff --git a/internal/command/deploy.go b/internal/command/deploy.go index ec90559..ff6b44d 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,20 +39,22 @@ 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 - } - - settings, err := d.flags.buildSettings(imageRef, host) + 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 { @@ -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 "+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/command/settings_flags.go b/internal/command/settings_flags.go index 2ec77a4..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().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().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, @@ -88,7 +91,10 @@ 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("canonical-host") { + s.CanonicalHost = f.canonicalHost } 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..f2ad1ff 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -20,16 +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") - 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.", } @@ -108,7 +110,7 @@ func (a *Application) URL() string { defaultPort = 443 } - base := scheme + "://" + a.Settings.Host + base := scheme + "://" + a.Settings.DisplayHost() if a.namespace == nil { return base @@ -378,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, - Host: a.Settings.Host, - 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 613b525..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) { @@ -109,6 +111,38 @@ 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 "" +} + +// 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 @@ -116,17 +150,33 @@ 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 + } + } + // 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 } 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 { 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 5d51f5d..bfc3e77 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -338,3 +338,85 @@ 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()) +} + +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) + } +} + +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/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..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 - Host string - TLS bool + AppName string + Target string + Hosts []string + CanonicalHost string + TLS bool } type Proxy struct { @@ -218,8 +219,12 @@ 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.CanonicalHost != "" { + args = append(args, "--canonical-host", opts.CanonicalHost) } if opts.TLS { diff --git a/internal/docker/proxy_test.go b/internal/docker/proxy_test.go index eac246b..96a62d8 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, }) @@ -59,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.go b/internal/ui/settings.go index c9906f7..eeac89f 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -243,10 +243,16 @@ 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 + 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 + return m, nil + } + } m.state = settingsStateDeploying m.app.Settings = msg.Settings m.progress = NewProgress(m.width, Colors.Border) 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, ""