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

Add support for path based routing #93

Open
wants to merge 1 commit 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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ Only one service at a time can route a specific host:
kamal-proxy deploy service2 --target web-2:3000 --host app1.example.com # succeeds


### Path-based routing

Path-based routing allows you to route traffic to different services based on the URL path prefix. This is useful when you want to run multiple applications under the same domain but different paths.

When deploying an instance, you can specify a path prefix that it should handle:

kamal-proxy deploy service1 --target web-1:3000 --prefix-path /api
kamal-proxy deploy service2 --target web-2:3000 --prefix-path /admin

The prefix path is stripped before forwarding the request to the target service

You can combine path-based routing with host-based routing:

kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --prefix-path /api


### Automatic TLS

Kamal Proxy can automatically obtain and renew TLS certificates for your
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ func newDeployCommand() *deployCommand {

deployCommand.cmd.Flags().StringVar(&deployCommand.args.TargetURL, "target", "", "Target host to deploy")
deployCommand.cmd.Flags().StringSliceVar(&deployCommand.args.Hosts, "host", []string{}, "Host(s) to serve this target on (empty for wildcard)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.PrefixPath, "prefix-path", "", "Path prefix for routing (e.g., /api)")

deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSEnabled, "tls", false, "Configure TLS for this target (requires a non-empty host)")
deployCommand.cmd.Flags().BoolVar(&deployCommand.tlsStaging, "tls-staging", false, "Use Let's Encrypt staging environment for certificate provisioning")
Expand Down
3 changes: 2 additions & 1 deletion internal/server/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type DeployArgs struct {
Service string
TargetURL string
Hosts []string
PrefixPath string
DeployTimeout time.Duration
DrainTimeout time.Duration
ServiceOptions ServiceOptions
Expand Down Expand Up @@ -114,7 +115,7 @@ func (h *CommandHandler) Close() error {
}

func (h *CommandHandler) Deploy(args DeployArgs, reply *bool) error {
return h.router.SetServiceTarget(args.Service, args.Hosts, args.TargetURL, args.ServiceOptions, args.TargetOptions, args.DeployTimeout, args.DrainTimeout)
return h.router.SetServiceTarget(args.Service, args.Hosts, args.PrefixPath, args.TargetURL, args.ServiceOptions, args.TargetOptions, args.DeployTimeout, args.DrainTimeout)
}

func (h *CommandHandler) Pause(args PauseArgs, reply *bool) error {
Expand Down
81 changes: 68 additions & 13 deletions internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ var (
ErrorServiceNotFound = errors.New("service not found")
ErrorTargetFailedToBecomeHealthy = errors.New("target failed to become healthy within configured timeout")
ErrorHostInUse = errors.New("host settings conflict with another service")
ErrorPathInUse = errors.New("path settings conflict with another service")
ErrorNoServerName = errors.New("no server name provided")
ErrorUnknownServerName = errors.New("unknown server name")
)
Expand All @@ -29,25 +30,37 @@ type (

func (m ServiceMap) HostServices() HostServiceMap {
hostServices := HostServiceMap{}

for _, service := range m {
if len(service.hosts) == 0 {
hostServices[""] = service
if service.prefixPath != "" {
hostServices[service.prefixPath] = service
} else {
hostServices[""] = service
}
continue
}

for _, host := range service.hosts {
hostServices[host] = service
if service.prefixPath != "" {
hostServices[host+service.prefixPath] = service
} else {
hostServices[host] = service
}
}
}

return hostServices
}

func (m HostServiceMap) CheckHostAvailability(name string, hosts []string) *Service {
func (m HostServiceMap) CheckHostAvailability(name string, hosts []string, prefixPath string) *Service {
if len(hosts) == 0 {
hosts = []string{""}
}

for _, host := range hosts {
service := m[host]
service := m[host+prefixPath]

if service != nil && service.name != name {
return service
}
Expand All @@ -72,6 +85,26 @@ func (m HostServiceMap) ServiceForHost(host string) *Service {
return m[""]
}

func (m HostServiceMap) ServiceForRoute(host string, path string) *Service {
if path != "" {
if service, ok := m[host+path]; ok {
return service
}
}

if service := m.ServiceForHost(host); service != nil {
return service
}

if path != "" {
if service, ok := m[path]; ok {
return service
}
}

return nil
}

type Router struct {
statePath string
services ServiceMap
Expand Down Expand Up @@ -139,25 +172,25 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
service.ServeHTTP(w, req)
}

func (r *Router) SetServiceTarget(name string, hosts []string, targetURL string,
func (r *Router) SetServiceTarget(name string, hosts []string, prefixPath string, targetURL string,
options ServiceOptions, targetOptions TargetOptions,
deployTimeout time.Duration, drainTimeout time.Duration,
) error {
defer r.saveStateSnapshot()

slog.Info("Deploying", "service", name, "hosts", hosts, "target", targetURL, "tls", options.TLSEnabled)
slog.Info("Deploying", "service", name, "hosts", hosts, "prefix_path", prefixPath, "target", targetURL, "tls", options.TLSEnabled)

target, err := r.deployNewTargetWithOptions(targetURL, targetOptions, deployTimeout)
if err != nil {
return err
}

err = r.setActiveTarget(name, hosts, target, options, drainTimeout)
err = r.setActiveTarget(name, hosts, prefixPath, target, options, drainTimeout)
if err != nil {
return err
}

slog.Info("Deployed", "service", name, "hosts", hosts, "target", targetURL)
slog.Info("Deployed", "service", name, "hosts", hosts, "prefix_path", prefixPath, "target", targetURL)
return nil
}

Expand Down Expand Up @@ -352,7 +385,23 @@ func (r *Router) serviceForRequest(req *http.Request) *Service {
host = req.Host
}

return r.serviceForHost(host)
path := getFirstPathSegment(req.URL.Path)
return r.hostServices.ServiceForRoute(host, path)
}

func getFirstPathSegment(path string) string {
if path == "" || path == "/" {
return ""
}

segments := strings.Split(path, "/")
for _, segment := range segments {
if segment != "" {
return "/" + segment
}
}

return ""
}

func (r *Router) serviceForHost(host string) *Service {
Expand All @@ -362,22 +411,28 @@ func (r *Router) serviceForHost(host string) *Service {
return r.hostServices.ServiceForHost(host)
}

func (r *Router) setActiveTarget(name string, hosts []string, target *Target, options ServiceOptions, drainTimeout time.Duration) error {
func (r *Router) setActiveTarget(name string, hosts []string, prefixPath string, target *Target, options ServiceOptions, drainTimeout time.Duration) error {
r.serviceLock.Lock()
defer r.serviceLock.Unlock()

conflict := r.hostServices.CheckHostAvailability(name, hosts)
conflict := r.hostServices.CheckHostAvailability(name, hosts, prefixPath)

if conflict != nil {
if prefixPath != "" && prefixPath == conflict.prefixPath {
slog.Error("Path settings conflict with another service", "service", conflict.name)
return ErrorPathInUse
}

slog.Error("Host settings conflict with another service", "service", conflict.name)
return ErrorHostInUse
}

var err error
service := r.services[name]
if service == nil {
service, err = NewService(name, hosts, options)
service, err = NewService(name, hosts, prefixPath, options)
} else {
err = service.UpdateOptions(hosts, options)
err = service.UpdateOptions(hosts, prefixPath, options)
}
if err != nil {
return err
Expand Down
Loading