Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ jobs:
context: .
platforms: linux/amd64,linux/arm64
push: true
# The dash gem prunes proxy images by this label - keep it kamal-proxy
# until the server-artifact rename ships a migration bridge.
# The dash gem prunes proxy images by this label. It matches both this
# and the legacy kamal-proxy title for the duration of the rename, so
# `dash proxy remove` still cleans up a pre-rename image.
labels: |
org.opencontainers.image.title=kamal-proxy
org.opencontainers.image.title=dash-proxy
tags: |
ghcr.io/zoolutions/dash-proxy:latest
ghcr.io/zoolutions/dash-proxy:${{ steps.version-tag.outputs.value }}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

### Never Do

1. **NO renaming of module/binary/RPC/socket** — `kamal-proxy` is load-bearing: the RPC name is registered once in `internal/server/commands.go` and dialed by 9 client call sites; the Dockerfile copies `bin/kamal-proxy`; the kamal gem execs `kamal-proxy run`
1. **NO renaming of the Go module path or the RPC service name** — the RPC name `kamal-proxy` is registered once in `internal/server/commands.go` and dialed by 9 client call sites; it is internal, spoken over a unix socket inside one container, and renaming it buys nothing. The module path `github.com/basecamp/kamal-proxy` is likewise deferred. Both have their own follow-up issue. The **binary, socket, image label, user and data directory DID move to `dash-proxy`** in stage 3c (zoolutions/dash#124) — do not "fix" those back
2. **NO upstream syncs** — the fork network is left and the `upstream` remote removed; `main` is this repo's only long-lived branch and everything lands there via PR
3. **NO suffix tags** like `v1.0.0-rc1` — the gem compares the image tag with `Gem::Version`, which reads a hyphen suffix as a prerelease sorting *below* the release it names
4. **NO publishing without the `org.opencontainers.image.title=kamal-proxy` label** — the dash gem prunes proxy images by it (set in `docker-publish.yml`); it stays `kamal-proxy` until the server-artifact rename ships a bridge
Expand Down
16 changes: 10 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ RUN make
FROM ubuntu:noble-20251013 AS base

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /app/bin/kamal-proxy /usr/local/bin/
COPY --from=build /app/bin/dash-proxy /usr/local/bin/

EXPOSE 80 443

RUN useradd kamal-proxy \
&& mkdir -p /home/kamal-proxy/.config/kamal-proxy \
&& chown -R kamal-proxy:kamal-proxy /home/kamal-proxy
# ubuntu:noble already ships a uid-1000 `ubuntu` user, so this lands on uid 1001
# under either name. That is what lets the gem copy the old kamal-proxy-config
# volume into dash-proxy-config with `cp -a` and no chown: the owning uid is
# unchanged by the rename.
RUN useradd dash-proxy \
&& mkdir -p /home/dash-proxy/.config/dash-proxy \
&& chown -R dash-proxy:dash-proxy /home/dash-proxy

USER kamal-proxy:kamal-proxy
USER dash-proxy:dash-proxy

CMD ["kamal-proxy", "run"]
CMD ["dash-proxy", "run"]
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ bench:
go test -bench=. -benchmem -run=^# ./...

docker:
docker build -t kamal-proxy .
docker build -t dash-proxy .
164 changes: 82 additions & 82 deletions README.md

Large diffs are not rendered by default.

File renamed without changes.
2 changes: 1 addition & 1 deletion internal/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ func newExportCertsCommand() *exportCertsCommand {
Args: cobra.ExactArgs(1),
}

exportCertsCommand.cmd.Flags().StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/kamal-proxy)")
exportCertsCommand.cmd.Flags().StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/dash-proxy)")

return exportCertsCommand
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func newImportCertsCommand() *importCertsCommand {
flags.StringVar(&importCertsCommand.archivePath, "archive", "", "Path to a certificate store archive written by `export certs`")
flags.BoolVar(&importCertsCommand.force, "force", false, "Overwrite a non-empty certificate store when restoring an archive")
flags.BoolVar(&importCertsCommand.verify, "verify", false, "Only verify the archive: parse every certificate and report domains and expiries, without touching the store")
flags.StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/kamal-proxy)")
flags.StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/dash-proxy)")

importCertsCommand.cmd.MarkFlagsOneRequired("traefik-acme", "archive")
importCertsCommand.cmd.MarkFlagsMutuallyExclusive("traefik-acme", "archive")
Expand Down
10 changes: 8 additions & 2 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().BoolVar(&globalConfig.HTTP3Enabled, "http3", false, "Enable HTTP/3")
runCommand.cmd.Flags().BoolVar(&runCommand.ignoreRestoreErrors, "ignore-restore-errors", getEnvBool("IGNORE_RESTORE_ERRORS", false), "Boot with an empty routing state when restoring the saved state fails")
runCommand.cmd.Flags().BoolVar(&runCommand.recheckTargetsOnRestore, "recheck-targets-on-restore", getEnvBool("RECHECK_TARGETS_ON_RESTORE", false), "Re-verify restored targets with health checks instead of assuming they are healthy")
runCommand.cmd.Flags().StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/kamal-proxy)")
runCommand.cmd.Flags().StringVar(&globalConfig.AlternateConfigDir, "data-dir", getEnvString("DATA_DIR", ""), "Directory for state and certificate storage (default $HOME/.config/dash-proxy)")
runCommand.cmd.Flags().BoolVar(&globalConfig.ReusePort, "reuse-port", getEnvBool("REUSE_PORT", false), "Bind listeners with SO_REUSEPORT so an overlapping proxy generation can share the ports during a handoff")
runCommand.cmd.Flags().BoolVar(&globalConfig.ProxyProtocol, "proxy-protocol", getEnvBool("PROXY_PROTOCOL", false), "Accept PROXY protocol v1/v2 headers on the HTTP and HTTPS listeners, preserving client addresses behind an L4 load balancer")
runCommand.cmd.Flags().StringSliceVar(&globalConfig.ProxyProtocolAllowIPs, "proxy-protocol-allow-ip", nil, "Honor PROXY protocol headers only from these addresses or CIDR ranges (default empty, trust every peer that can reach the port)")
Expand Down Expand Up @@ -114,6 +114,12 @@ func (c *runCommand) run(cmd *cobra.Command, args []string) error {
return err
}

// Before the router reads it: a volume copied from a pre-rename proxy holds
// the routing table under the old file name.
if err := globalConfig.AdoptLegacyState(); err != nil {
return err
}

router := server.NewRouter(globalConfig.StatePath())

if c.recheckTargetsOnRestore {
Expand Down Expand Up @@ -206,7 +212,7 @@ func (c *runCommand) run(cmd *cobra.Command, args []string) error {

select {
case <-ch:
// Converge on the drain path so SIGTERM and `kamal-proxy drain`
// Converge on the drain path so SIGTERM and `dash-proxy drain`
// behave identically; the deferred Stop finishes the teardown.
_ = s.BeginDrain(0)
case <-s.ShutdownRequested():
Expand Down
60 changes: 58 additions & 2 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package server

import (
"cmp"
"errors"
"log/slog"
"os"
"path"
"syscall"
Expand Down Expand Up @@ -128,14 +130,68 @@ type Config struct {
ACMEDNSProviderZones map[string]acme.ProviderName
}

// SocketPath is recreated on every boot, so the name is free to move. The
// legacy environment override is still honored: the gem sets KAMAL_PROXY_SOCKET
// on containers it booted before the rename, and a running proxy is reached
// over whatever socket it opened.
func (c Config) SocketPath() string {
return cmp.Or(os.Getenv("KAMAL_PROXY_SOCKET"), path.Join(c.runtimeDirectory(), "kamal-proxy.sock"))
return cmp.Or(
os.Getenv("DASH_PROXY_SOCKET"),
os.Getenv("KAMAL_PROXY_SOCKET"),
path.Join(c.runtimeDirectory(), "dash-proxy.sock"),
)
}

// StatePath is the routing table, always under the current name. Recovering a
// pre-rename table is AdoptLegacyState's job, called once at boot.
func (c Config) StatePath() string {
return path.Join(c.dataDirectory(), "dash-proxy.state")
}

// LegacyStatePath is the pre-rename routing table, which the gem's volume copy
// carries into the new volume verbatim.
func (c Config) LegacyStatePath() string {
return path.Join(c.dataDirectory(), "kamal-proxy.state")
}

// AdoptLegacyState seeds the current state file from the pre-rename one when
// the data directory has only the old name — the shape the gem's volume copy
// leaves behind. Without it the proxy boots with an empty routing table and
// every service has to be re-registered by a deploy, which is an outage rather
// than a migration.
//
// It copies rather than renames, so the legacy file survives: an operator who
// rolls back to a pre-rename image must still find the table where that image
// looks for it. Stage 3d removes both the copy and the leftover.
//
// Doing this once at boot, rather than teaching StatePath to return whichever
// file exists, is what makes the host converge. Reading *and writing* the old
// name would leave every upgraded host on the legacy filename forever, and the
// fallback could never be retired.
func (c Config) AdoptLegacyState() error {
current, legacy := c.StatePath(), c.LegacyStatePath()

if _, err := os.Stat(current); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}

contents, err := os.ReadFile(legacy)
if errors.Is(err, os.ErrNotExist) {
return nil // A first boot, not an upgrade.
} else if err != nil {
return err
}

if err := os.WriteFile(current, contents, 0o600); err != nil {
return err
}

slog.Info("Adopted the pre-rename routing table", "from", legacy, "to", current)
return nil
}

// StateBackupPath is the last-known-good copy of StatePath, written after each
// clean restore and used to recover from a torn or corrupted state file.
func (c Config) StateBackupPath() string {
Expand Down Expand Up @@ -217,7 +273,7 @@ func (c Config) defaultDataDirectory() string {
home = os.TempDir()
}

dir := path.Join(home, ".config", "kamal-proxy")
dir := path.Join(home, ".config", "dash-proxy")

err = os.MkdirAll(dir, syscall.S_IRUSR|syscall.S_IWUSR|syscall.S_IXUSR)
if err != nil {
Expand Down
95 changes: 94 additions & 1 deletion internal/server/config_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package server

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/basecamp/kamal-proxy/internal/server/acme"
)
Expand All @@ -20,18 +22,109 @@ func TestConfig_StatePathHonorsAlternateConfigDir(t *testing.T) {
dir := t.TempDir()
config := Config{AlternateConfigDir: dir}

assert.Equal(t, filepath.Join(dir, "kamal-proxy.state"), config.StatePath())
assert.Equal(t, filepath.Join(dir, "dash-proxy.state"), config.StatePath())
assert.Equal(t, filepath.Join(dir, "certs"), config.CertificatePath())
}

// The gem copies the old kamal-proxy-config volume into dash-proxy-config
// verbatim, so a freshly renamed proxy finds a data directory holding
// kamal-proxy.state and no dash-proxy.state. Seeding the current file from it
// is what keeps the routing table across the rename; without it the proxy boots
// empty and every service has to be re-registered by a deploy.
func TestConfig_AdoptLegacyStateSeedsTheCurrentFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "kamal-proxy.state"), []byte(`{"services":[]}`), 0o600))

config := Config{AlternateConfigDir: dir}
require.NoError(t, config.AdoptLegacyState())

contents, err := os.ReadFile(config.StatePath())
require.NoError(t, err)
assert.JSONEq(t, `{"services":[]}`, string(contents))
}

// Copied, not renamed: an operator who rolls back to a pre-rename image has to
// find the table where that image looks for it.
func TestConfig_AdoptLegacyStateLeavesTheLegacyFileInPlace(t *testing.T) {
dir := t.TempDir()
legacy := filepath.Join(dir, "kamal-proxy.state")
require.NoError(t, os.WriteFile(legacy, []byte(`{"services":[]}`), 0o600))

config := Config{AlternateConfigDir: dir}
require.NoError(t, config.AdoptLegacyState())

assert.FileExists(t, legacy)
}

// Adoption happens once. A later boot must not clobber a live routing table
// with whatever the stale legacy file still holds.
func TestConfig_AdoptLegacyStateNeverOverwritesTheCurrentFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "kamal-proxy.state"), []byte(`{"services":["stale"]}`), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "dash-proxy.state"), []byte(`{"services":[]}`), 0o600))

config := Config{AlternateConfigDir: dir}
require.NoError(t, config.AdoptLegacyState())

contents, err := os.ReadFile(config.StatePath())
require.NoError(t, err)
assert.JSONEq(t, `{"services":[]}`, string(contents))
}

// A first boot has neither file and is not an upgrade.
func TestConfig_AdoptLegacyStateIsANoopWithNothingToAdopt(t *testing.T) {
config := Config{AlternateConfigDir: t.TempDir()}

require.NoError(t, config.AdoptLegacyState())
assert.NoFileExists(t, config.StatePath())
}

// StatePath always names the current file, so an upgraded host converges onto
// it instead of writing the legacy name forever — which is what would make the
// fallback impossible to retire in stage 3d.
func TestConfig_StatePathAlwaysNamesTheCurrentFile(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "kamal-proxy.state"), []byte(`{"services":[]}`), 0o600))

config := Config{AlternateConfigDir: dir}
assert.Equal(t, filepath.Join(dir, "dash-proxy.state"), config.StatePath())
assert.Equal(t, filepath.Join(dir, "kamal-proxy.state"), config.LegacyStatePath())
}

func TestConfig_SocketPathHonorsEnvOverride(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "custom.sock")
t.Setenv("DASH_PROXY_SOCKET", socketPath)

config := Config{}
assert.Equal(t, socketPath, config.SocketPath())
}

// The gem sets KAMAL_PROXY_SOCKET on containers it booted before the rename,
// and a running proxy is talked to over whatever socket it opened.
func TestConfig_SocketPathStillHonorsTheLegacyEnvOverride(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "legacy.sock")
t.Setenv("KAMAL_PROXY_SOCKET", socketPath)

config := Config{}
assert.Equal(t, socketPath, config.SocketPath())
}

func TestConfig_SocketPathPrefersTheCurrentEnvOverride(t *testing.T) {
current := filepath.Join(t.TempDir(), "current.sock")
t.Setenv("DASH_PROXY_SOCKET", current)
t.Setenv("KAMAL_PROXY_SOCKET", filepath.Join(t.TempDir(), "legacy.sock"))

config := Config{}
assert.Equal(t, current, config.SocketPath())
}

func TestConfig_SocketPathDefaultsToTheCurrentName(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())

config := Config{}
assert.Equal(t, "dash-proxy.sock", filepath.Base(config.SocketPath()))
}

// Everything ACME writes -- the account key, the certificates, the manager's
// state -- has to land inside the directory the container image creates and the
// gem mounts as a volume, or a restart loses the account and re-orders every
Expand Down