From bd70894dfb0362aa1f22d82f8b73ac060476fa4c Mon Sep 17 00:00:00 2001 From: Tom Groves Date: Fri, 20 Mar 2026 11:33:13 +0000 Subject: [PATCH 1/3] feat: add NITRIC_HOSTNAME env var for configurable local backend address Presigned storage URLs, gateway addresses, and website URLs were hardcoded to localhost, making them unresolvable when the backend runs in a Docker container addressed by other containers. Introduce NITRIC_HOSTNAME (default: "localhost") so all generated URLs use a configurable host, resolved once in cloud.New() and threaded through storage, gateway, and website services. --- pkg/cloud/cloud.go | 7 ++++++- pkg/cloud/env/env.go | 4 ++++ pkg/cloud/gateway/gateway.go | 16 ++++++++++++---- pkg/cloud/storage/storage.go | 14 ++++++++++++-- pkg/cloud/websites/websites.go | 10 ++++++++-- 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index 368fd1dfc..1315730b3 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -38,6 +38,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/topics" "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/cloud/websockets" + "github.com/nitrictech/cli/pkg/cloud/env" "github.com/nitrictech/cli/pkg/grpcx" "github.com/nitrictech/cli/pkg/netx" "github.com/nitrictech/cli/pkg/project/dockerhost" @@ -255,6 +256,8 @@ type LocalCloudOptions struct { } func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { + hostname := env.NITRIC_HOSTNAME.String() + localTopics, err := topics.NewLocalTopicsService() if err != nil { return nil, err @@ -268,6 +271,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { localStorage, err := storage.NewLocalStorageService(storage.StorageOptions{ AccessKey: "dummykey", SecretKey: "dummysecret", + Hostname: hostname, }) if err != nil { return nil, err @@ -283,6 +287,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { LogWriter: opts.LogWriter, LocalConfig: opts.LocalConfig, BatchPlugin: localBatch, + Hostname: hostname, }) if err != nil { return nil, err @@ -321,7 +326,7 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { return nil, err } - localWebsites := websites.NewLocalWebsitesService(localGateway.GetApiAddress, localGateway.GetWebsocketAddress, opts.LocalCloudMode == StartMode) + localWebsites := websites.NewLocalWebsitesService(localGateway.GetApiAddress, localGateway.GetWebsocketAddress, opts.LocalCloudMode == StartMode, hostname) return &LocalCloud{ servers: make(map[string]*server.NitricServer), diff --git a/pkg/cloud/env/env.go b/pkg/cloud/env/env.go index e25013d2e..6eca232af 100644 --- a/pkg/cloud/env/env.go +++ b/pkg/cloud/env/env.go @@ -37,3 +37,7 @@ var ( ) var MAX_WORKERS = env.GetEnv("MAX_WORKERS", "300") + +// The hostname that the local Nitric backend is reachable at. +// Override when the backend runs in a container and callers address it by container name/IP. +var NITRIC_HOSTNAME = env.GetEnv("NITRIC_HOSTNAME", "localhost") diff --git a/pkg/cloud/gateway/gateway.go b/pkg/cloud/gateway/gateway.go index fd857609b..fcb7020ac 100644 --- a/pkg/cloud/gateway/gateway.go +++ b/pkg/cloud/gateway/gateway.go @@ -96,6 +96,7 @@ type LocalGatewayService struct { serviceListener net.Listener localConfig localconfig.LocalConfiguration + hostname string logWriter io.Writer @@ -114,7 +115,7 @@ var _ gateway.GatewayService = &LocalGatewayService{} // GetTriggerAddress - Returns the base address built-in nitric services, like schedules and topics, will be exposed on. func (s *LocalGatewayService) GetTriggerAddress() string { if s.serviceListener != nil { - return strings.Replace(s.serviceListener.Addr().String(), "[::]", "localhost", 1) + return strings.Replace(s.serviceListener.Addr().String(), "[::]", s.hostname, 1) } return "" @@ -134,7 +135,7 @@ func (s *LocalGatewayService) GetApiAddresses() map[string]string { protocol = "https" } - address := strings.Replace(srv.lis.Addr().String(), "[::]", "localhost", 1) + address := strings.Replace(srv.lis.Addr().String(), "[::]", s.hostname, 1) addresses[srv.name] = fmt.Sprintf("%s://%s", protocol, address) } @@ -182,7 +183,7 @@ func (s *LocalGatewayService) GetHttpWorkerAddresses() map[string]string { protocol = "https" } - address := strings.Replace(srv.lis.Addr().String(), "[::]", "localhost", 1) + address := strings.Replace(srv.lis.Addr().String(), "[::]", s.hostname, 1) addresses[srv.name] = fmt.Sprintf("%s://%s", protocol, address) } @@ -199,7 +200,7 @@ func (s *LocalGatewayService) GetWebsocketAddresses() map[string]string { for socket, srv := range s.socketServer { if srv.workerCount > 0 { - srvAddress := strings.Replace(srv.lis.Addr().String(), "[::]", "localhost", 1) + srvAddress := strings.Replace(srv.lis.Addr().String(), "[::]", s.hostname, 1) addresses[socket] = srvAddress } } @@ -932,16 +933,23 @@ type NewGatewayOpts struct { LogWriter io.Writer LocalConfig localconfig.LocalConfiguration BatchPlugin *batch.LocalBatchService + Hostname string } // Create new HTTP gateway // XXX: No External Args for function atm (currently the plugin loader does not pass any argument information) func NewGateway(opts NewGatewayOpts) (*LocalGatewayService, error) { + hostname := opts.Hostname + if hostname == "" { + hostname = "localhost" + } + return &LocalGatewayService{ ApiTlsCredentials: opts.TLSCredentials, bus: EventBus.New(), logWriter: opts.LogWriter, localConfig: opts.LocalConfig, batchPlugin: opts.BatchPlugin, + hostname: hostname, }, nil } diff --git a/pkg/cloud/storage/storage.go b/pkg/cloud/storage/storage.go index 5547b9c0f..92c1989da 100644 --- a/pkg/cloud/storage/storage.go +++ b/pkg/cloud/storage/storage.go @@ -85,6 +85,7 @@ type LocalStorageService struct { listeners State storageListener net.Listener + hostname string bus EventBus.Bus } @@ -472,11 +473,13 @@ func (r *LocalStorageService) PreSignUrl(ctx context.Context, req *storagepb.Sto // XXX: Do not URL encode keys (path needs to be preserved) // TODO: May need to re-write slashes to a non-escapable character format + port := r.storageListener.Addr().(*net.TCPAddr).Port + switch req.Operation { case storagepb.StoragePreSignUrlRequest_WRITE: - address = fmt.Sprintf("http://localhost:%d/write/%s", r.storageListener.Addr().(*net.TCPAddr).Port, tokenString) + address = fmt.Sprintf("http://%s:%d/write/%s", r.hostname, port, tokenString) case storagepb.StoragePreSignUrlRequest_READ: - address = fmt.Sprintf("http://localhost:%d/read/%s", r.storageListener.Addr().(*net.TCPAddr).Port, tokenString) + address = fmt.Sprintf("http://%s:%d/read/%s", r.hostname, port, tokenString) } if address == "" { @@ -491,6 +494,7 @@ func (r *LocalStorageService) PreSignUrl(ctx context.Context, req *storagepb.Sto type StorageOptions struct { AccessKey string SecretKey string + Hostname string } func corsMiddleware(next http.Handler) http.Handler { @@ -511,8 +515,14 @@ func corsMiddleware(next http.Handler) http.Handler { func NewLocalStorageService(opts StorageOptions) (*LocalStorageService, error) { var err error + hostname := opts.Hostname + if hostname == "" { + hostname = "localhost" + } + storageService := &LocalStorageService{ listeners: map[string]map[string]int{}, + hostname: hostname, bus: EventBus.New(), } diff --git a/pkg/cloud/websites/websites.go b/pkg/cloud/websites/websites.go index 26afb3bef..76d22aef1 100644 --- a/pkg/cloud/websites/websites.go +++ b/pkg/cloud/websites/websites.go @@ -62,6 +62,7 @@ type LocalWebsiteService struct { getApiAddress GetApiAddress getWebsocketAddress GetApiAddress isStartCmd bool + hostname string bus EventBus.Bus } @@ -99,7 +100,7 @@ func (l *LocalWebsiteService) register(website Website, port int) { defer l.websiteRegLock.Unlock() // Emulates the CDN URL used in a deployed environment - publicUrl := fmt.Sprintf("http://localhost:%d/%s", port, strings.TrimPrefix(website.BasePath, "/")) + publicUrl := fmt.Sprintf("http://%s:%d/%s", l.hostname, port, strings.TrimPrefix(website.BasePath, "/")) l.state[website.Name] = Website{ WebsitePb: website.WebsitePb, @@ -397,12 +398,17 @@ func (l *LocalWebsiteService) Start(websites []Website) error { return nil } -func NewLocalWebsitesService(getApiAddress GetApiAddress, getWebsocketAddress GetApiAddress, isStartCmd bool) *LocalWebsiteService { +func NewLocalWebsitesService(getApiAddress GetApiAddress, getWebsocketAddress GetApiAddress, isStartCmd bool, hostname string) *LocalWebsiteService { + if hostname == "" { + hostname = "localhost" + } + return &LocalWebsiteService{ state: State{}, bus: EventBus.New(), getApiAddress: getApiAddress, getWebsocketAddress: getWebsocketAddress, isStartCmd: isStartCmd, + hostname: hostname, } } From c44b87fa80e51ce43085aed78a63e3b07be7d551 Mon Sep 17 00:00:00 2001 From: Tom Groves Date: Fri, 20 Mar 2026 11:44:33 +0000 Subject: [PATCH 2/3] fix: alphabetize imports, bracket IPv6 hostnames, clarify SQL host - Move env import to correct alphabetical position - Wrap IPv6 literal hostnames in brackets for valid URL construction - Add comment explaining why connectionStringHost is separate from NITRIC_HOSTNAME --- pkg/cloud/cloud.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index 1315730b3..d0b0946a6 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -19,6 +19,7 @@ package cloud import ( "fmt" "io" + "strings" "sync" "google.golang.org/grpc" @@ -35,10 +36,10 @@ import ( "github.com/nitrictech/cli/pkg/cloud/secrets" "github.com/nitrictech/cli/pkg/cloud/sql" "github.com/nitrictech/cli/pkg/cloud/storage" + "github.com/nitrictech/cli/pkg/cloud/env" "github.com/nitrictech/cli/pkg/cloud/topics" "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/cloud/websockets" - "github.com/nitrictech/cli/pkg/cloud/env" "github.com/nitrictech/cli/pkg/grpcx" "github.com/nitrictech/cli/pkg/netx" "github.com/nitrictech/cli/pkg/project/dockerhost" @@ -258,6 +259,11 @@ type LocalCloudOptions struct { func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { hostname := env.NITRIC_HOSTNAME.String() + // Bracket IPv6 literals for use in URLs (RFC 3986) + if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") { + hostname = "[" + hostname + "]" + } + localTopics, err := topics.NewLocalTopicsService() if err != nil { return nil, err @@ -314,6 +320,9 @@ func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { return nil, err } + // connectionStringHost controls how application services reach the Postgres container, + // which is distinct from NITRIC_HOSTNAME (how callers reach the Nitric backend). + // Postgres is its own container with host-mapped ports, so it needs separate addressing. connectionStringHost := "localhost" // Use the host.docker.internal address for connection strings with local cloud run mode From 3db9207c1d160352960b1083eef05774bca0eb99 Mon Sep 17 00:00:00 2001 From: Tom Groves Date: Fri, 20 Mar 2026 12:21:28 +0000 Subject: [PATCH 3/3] fix: correct import ordering and validate NITRIC_HOSTNAME - Move env import to correct alphabetical position (between batch and gateway) - Reject NITRIC_HOSTNAME values containing URL-unsafe characters (/?#, newlines, whitespace) at startup to prevent malformed URLs --- pkg/cloud/cloud.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/cloud/cloud.go b/pkg/cloud/cloud.go index d0b0946a6..b51fd8e3e 100644 --- a/pkg/cloud/cloud.go +++ b/pkg/cloud/cloud.go @@ -27,6 +27,7 @@ import ( "github.com/nitrictech/cli/pkg/cloud/apis" "github.com/nitrictech/cli/pkg/cloud/batch" + "github.com/nitrictech/cli/pkg/cloud/env" "github.com/nitrictech/cli/pkg/cloud/gateway" "github.com/nitrictech/cli/pkg/cloud/http" "github.com/nitrictech/cli/pkg/cloud/keyvalue" @@ -36,7 +37,6 @@ import ( "github.com/nitrictech/cli/pkg/cloud/secrets" "github.com/nitrictech/cli/pkg/cloud/sql" "github.com/nitrictech/cli/pkg/cloud/storage" - "github.com/nitrictech/cli/pkg/cloud/env" "github.com/nitrictech/cli/pkg/cloud/topics" "github.com/nitrictech/cli/pkg/cloud/websites" "github.com/nitrictech/cli/pkg/cloud/websockets" @@ -259,6 +259,11 @@ type LocalCloudOptions struct { func New(projectName string, opts LocalCloudOptions) (*LocalCloud, error) { hostname := env.NITRIC_HOSTNAME.String() + // Reject hostnames containing characters that would produce malformed URLs + if strings.ContainsAny(hostname, "/?#\r\n \t") { + return nil, fmt.Errorf("NITRIC_HOSTNAME contains invalid characters: %q", hostname) + } + // Bracket IPv6 literals for use in URLs (RFC 3986) if strings.Contains(hostname, ":") && !strings.HasPrefix(hostname, "[") { hostname = "[" + hostname + "]"