From 976bd569958d5fd34698ac839c82e060ebafc260 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 24 Jun 2026 11:40:31 -0500 Subject: [PATCH 1/2] feat(network): add proxy-mode fields to Route + AddRouteRequest --- network/network.go | 7 ++++++- network/service.go | 9 +++++++-- network/service_impl.go | 9 +++++++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/network/network.go b/network/network.go index 85f3793..b3f0bb9 100644 --- a/network/network.go +++ b/network/network.go @@ -36,7 +36,12 @@ type Route struct { Port int `db:"port" json:"port"` Protocol string `db:"protocol" json:"protocol"` Weight int `db:"weight" json:"weight"` - StripPrefix bool `db:"strip_prefix" json:"strip_prefix"` + StripPrefix bool `db:"strip_prefix" json:"strip_prefix"` + PathMode string `db:"path_mode" json:"path_mode,omitempty"` + RewriteRedirects bool `db:"rewrite_redirects" json:"rewrite_redirects,omitempty"` + RewriteCookiePath bool `db:"rewrite_cookie_path" json:"rewrite_cookie_path,omitempty"` + UpstreamOrigin string `db:"upstream_origin" json:"upstream_origin,omitempty"` + TLSVerify bool `db:"tls_verify" json:"tls_verify"` // Hostname, when set, scopes the route to a single host (the // workspace's API hostname). The OctopusRouter uses it as the // Gateway API HTTPRoute's `hostnames` entry so per-workspace path diff --git a/network/service.go b/network/service.go index f20d3b5..77768b0 100644 --- a/network/service.go +++ b/network/service.go @@ -58,8 +58,13 @@ type AddRouteRequest struct { Port int `json:"port" validate:"required"` Protocol string `default:"http" json:"protocol"` Weight int `default:"100" json:"weight"` - StripPrefix bool `json:"strip_prefix,omitempty"` - Hostname string `json:"hostname,omitempty"` + StripPrefix bool `json:"strip_prefix,omitempty"` + PathMode string `json:"path_mode,omitempty"` + RewriteRedirects bool `json:"rewrite_redirects,omitempty"` + RewriteCookiePath bool `json:"rewrite_cookie_path,omitempty"` + UpstreamOrigin string `json:"upstream_origin,omitempty"` + TLSVerify *bool `json:"tls_verify,omitempty"` + Hostname string `json:"hostname,omitempty"` } // UpdateRouteRequest holds the parameters for modifying a route. diff --git a/network/service_impl.go b/network/service_impl.go index b28f38c..d1b9318 100644 --- a/network/service_impl.go +++ b/network/service_impl.go @@ -187,8 +187,13 @@ func (s *service) AddRoute(ctx context.Context, req AddRouteRequest) (*Route, er Port: req.Port, Protocol: protocol, Weight: weight, - StripPrefix: req.StripPrefix, - Hostname: req.Hostname, + StripPrefix: req.StripPrefix, + PathMode: req.PathMode, + RewriteRedirects: req.RewriteRedirects, + RewriteCookiePath: req.RewriteCookiePath, + UpstreamOrigin: req.UpstreamOrigin, + TLSVerify: req.TLSVerify == nil || *req.TLSVerify, + Hostname: req.Hostname, } if err := s.store.InsertRoute(ctx, route); err != nil { From 98a4c4f971b64e75425b602bbaa18af308752604 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 24 Jun 2026 11:48:34 -0500 Subject: [PATCH 2/2] feat(store): persist Route proxy-mode fields (pg/sqlite/mongo) + migrations --- store/mongo/models.go | 44 +++++++++++++++++++------- store/postgres/migrations.go | 20 ++++++++++++ store/postgres/models.go | 38 ++++++++++++++++------- store/postgres/network.go | 6 ++++ store/postgres/network_test.go | 57 ++++++++++++++++++++++++++++++++++ store/sqlite/migrations.go | 39 +++++++++++++++++++++++ store/sqlite/models.go | 44 +++++++++++++++++++------- 7 files changed, 212 insertions(+), 36 deletions(-) create mode 100644 store/postgres/network_test.go diff --git a/store/mongo/models.go b/store/mongo/models.go index f210fb4..9e91383 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -604,16 +604,23 @@ func fromDomainModel(m *domainModel) *network.Domain { type routeModel struct { grove.BaseModel `grove:"table:cp_routes"` - ID string `bson:"_id" grove:"id,pk"` - TenantID string `bson:"tenant_id" grove:"tenant_id"` - InstanceID string `bson:"instance_id" grove:"instance_id"` - Path string `bson:"path" grove:"path"` - Port int `bson:"port" grove:"port"` - Protocol string `bson:"protocol,omitempty" grove:"protocol"` - Weight int `bson:"weight" grove:"weight"` - StripPrefix bool `bson:"strip_prefix" grove:"strip_prefix"` - CreatedAt time.Time `bson:"created_at" grove:"created_at"` - UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` + ID string `bson:"_id" grove:"id,pk"` + TenantID string `bson:"tenant_id" grove:"tenant_id"` + InstanceID string `bson:"instance_id" grove:"instance_id"` + Path string `bson:"path" grove:"path"` + Port int `bson:"port" grove:"port"` + Protocol string `bson:"protocol,omitempty" grove:"protocol"` + Weight int `bson:"weight" grove:"weight"` + StripPrefix bool `bson:"strip_prefix" grove:"strip_prefix"` + + PathMode string `bson:"path_mode" grove:"path_mode"` + RewriteRedirects bool `bson:"rewrite_redirects" grove:"rewrite_redirects"` + RewriteCookiePath bool `bson:"rewrite_cookie_path" grove:"rewrite_cookie_path"` + UpstreamOrigin string `bson:"upstream_origin" grove:"upstream_origin"` + TLSVerify bool `bson:"tls_verify" grove:"tls_verify"` + + CreatedAt time.Time `bson:"created_at" grove:"created_at"` + UpdatedAt time.Time `bson:"updated_at" grove:"updated_at"` } func toRouteModel(r *network.Route) *routeModel { @@ -626,8 +633,15 @@ func toRouteModel(r *network.Route) *routeModel { Protocol: r.Protocol, Weight: r.Weight, StripPrefix: r.StripPrefix, - CreatedAt: r.CreatedAt, - UpdatedAt: r.UpdatedAt, + + PathMode: r.PathMode, + RewriteRedirects: r.RewriteRedirects, + RewriteCookiePath: r.RewriteCookiePath, + UpstreamOrigin: r.UpstreamOrigin, + TLSVerify: r.TLSVerify, + + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, } } @@ -645,6 +659,12 @@ func fromRouteModel(m *routeModel) *network.Route { Protocol: m.Protocol, Weight: m.Weight, StripPrefix: m.StripPrefix, + + PathMode: m.PathMode, + RewriteRedirects: m.RewriteRedirects, + RewriteCookiePath: m.RewriteCookiePath, + UpstreamOrigin: m.UpstreamOrigin, + TLSVerify: m.TLSVerify, } } diff --git a/store/postgres/migrations.go b/store/postgres/migrations.go index 85f2cfa..b69b6f4 100644 --- a/store/postgres/migrations.go +++ b/store/postgres/migrations.go @@ -843,6 +843,26 @@ CREATE INDEX IF NOT EXISTS idx_cp_workloads_state ON cp_workloads (state); Down: func(ctx context.Context, exec migrate.Executor) error { _, err := exec.Exec(ctx, `DROP TABLE IF EXISTS cp_workloads`) + return err + }, + }, + // VirtualGateway proxy-mode fields on routes. Octopus reads these + // to decide redirect/cookie rewriting, the upstream origin, and + // whether to verify upstream TLS. Additive — existing routes get + // safe defaults (empty/false; tls_verify defaults TRUE). + // Single comma-separated ALTER so grove's `;`-split executor runs + // it as one statement. + &migrate.Migration{ + Name: "add_proxy_fields_to_cp_routes", + Version: "20240101000026", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE cp_routes ADD COLUMN IF NOT EXISTS path_mode TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS rewrite_redirects BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS rewrite_cookie_path BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS upstream_origin TEXT NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS tls_verify BOOLEAN NOT NULL DEFAULT TRUE`) + + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE cp_routes DROP COLUMN IF EXISTS path_mode, DROP COLUMN IF EXISTS rewrite_redirects, DROP COLUMN IF EXISTS rewrite_cookie_path, DROP COLUMN IF EXISTS upstream_origin, DROP COLUMN IF EXISTS tls_verify`) + return err }, }, diff --git a/store/postgres/models.go b/store/postgres/models.go index 28ab2f5..c31a2d4 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -216,16 +216,23 @@ type domainModel struct { type routeModel struct { grove.BaseModel `grove:"table:cp_routes"` - ID string `grove:"id,pk"` - TenantID string `grove:"tenant_id,notnull"` - InstanceID string `grove:"instance_id,notnull"` - Path string `grove:"path,notnull"` - Port int `grove:"port,notnull"` - Protocol string `grove:"protocol"` - Weight int `grove:"weight"` - StripPrefix bool `grove:"strip_prefix"` - CreatedAt time.Time `grove:"created_at,notnull"` - UpdatedAt time.Time `grove:"updated_at,notnull"` + ID string `grove:"id,pk"` + TenantID string `grove:"tenant_id,notnull"` + InstanceID string `grove:"instance_id,notnull"` + Path string `grove:"path,notnull"` + Port int `grove:"port,notnull"` + Protocol string `grove:"protocol"` + Weight int `grove:"weight"` + StripPrefix bool `grove:"strip_prefix"` + + PathMode string `grove:"path_mode"` + RewriteRedirects bool `grove:"rewrite_redirects"` + RewriteCookiePath bool `grove:"rewrite_cookie_path"` + UpstreamOrigin string `grove:"upstream_origin"` + TLSVerify bool `grove:"tls_verify"` + + CreatedAt time.Time `grove:"created_at,notnull"` + UpdatedAt time.Time `grove:"updated_at,notnull"` } // certificateModel is the database model for network.Certificate. @@ -523,8 +530,15 @@ func toRouteModel(route *network.Route) *routeModel { Protocol: route.Protocol, Weight: route.Weight, StripPrefix: route.StripPrefix, - CreatedAt: route.CreatedAt, - UpdatedAt: route.UpdatedAt, + + PathMode: route.PathMode, + RewriteRedirects: route.RewriteRedirects, + RewriteCookiePath: route.RewriteCookiePath, + UpstreamOrigin: route.UpstreamOrigin, + TLSVerify: route.TLSVerify, + + CreatedAt: route.CreatedAt, + UpdatedAt: route.UpdatedAt, } } diff --git a/store/postgres/network.go b/store/postgres/network.go index bc3dbc8..fb878bb 100644 --- a/store/postgres/network.go +++ b/store/postgres/network.go @@ -357,6 +357,12 @@ func fromRouteModel(m *routeModel) *network.Route { Protocol: m.Protocol, Weight: m.Weight, StripPrefix: m.StripPrefix, + + PathMode: m.PathMode, + RewriteRedirects: m.RewriteRedirects, + RewriteCookiePath: m.RewriteCookiePath, + UpstreamOrigin: m.UpstreamOrigin, + TLSVerify: m.TLSVerify, } } diff --git a/store/postgres/network_test.go b/store/postgres/network_test.go new file mode 100644 index 0000000..317cc6e --- /dev/null +++ b/store/postgres/network_test.go @@ -0,0 +1,57 @@ +package postgres + +import ( + "testing" + + ctrlplane "github.com/xraph/ctrlplane" + "github.com/xraph/ctrlplane/id" + "github.com/xraph/ctrlplane/network" +) + +// TestRouteModel_ProxyFieldsRoundTrip guards the VirtualGateway +// proxy-mode fields through the model⇄domain mapping. Before they were +// wired into toRouteModel/fromRouteModel, octopus would read zero values +// (no redirect rewrite, tls_verify implicitly off) after a store reload. +func TestRouteModel_ProxyFieldsRoundTrip(t *testing.T) { + t.Parallel() + + in := &network.Route{ + Entity: ctrlplane.NewEntity(id.PrefixRoute), + TenantID: "tenant-x", + InstanceID: id.New(id.PrefixInstance), + Path: "/twinos", + Port: 7900, + Protocol: "http", + Weight: 1, + StripPrefix: true, + + PathMode: "strip", + RewriteRedirects: true, + RewriteCookiePath: true, + UpstreamOrigin: "https://x:443", + TLSVerify: false, + } + + m := toRouteModel(in) + if m.PathMode != "strip" || !m.RewriteRedirects || !m.RewriteCookiePath || + m.UpstreamOrigin != "https://x:443" || m.TLSVerify { + t.Fatalf("toRouteModel dropped a proxy field: %+v", m) + } + + out := fromRouteModel(m) + if out.PathMode != in.PathMode { + t.Fatalf("PathMode round-trip: got %q want %q", out.PathMode, in.PathMode) + } + if out.RewriteRedirects != in.RewriteRedirects { + t.Fatalf("RewriteRedirects round-trip: got %v want %v", out.RewriteRedirects, in.RewriteRedirects) + } + if out.RewriteCookiePath != in.RewriteCookiePath { + t.Fatalf("RewriteCookiePath round-trip: got %v want %v", out.RewriteCookiePath, in.RewriteCookiePath) + } + if out.UpstreamOrigin != in.UpstreamOrigin { + t.Fatalf("UpstreamOrigin round-trip: got %q want %q", out.UpstreamOrigin, in.UpstreamOrigin) + } + if out.TLSVerify != in.TLSVerify { + t.Fatalf("TLSVerify round-trip: got %v want %v", out.TLSVerify, in.TLSVerify) + } +} diff --git a/store/sqlite/migrations.go b/store/sqlite/migrations.go index 8b55c4a..0e5ab51 100644 --- a/store/sqlite/migrations.go +++ b/store/sqlite/migrations.go @@ -687,5 +687,44 @@ CREATE TABLE IF NOT EXISTS cp_bootstrap_workloads ( return err }, }, + // VirtualGateway proxy-mode fields on routes. Mirrors postgres + // migration 20240101000026. SQLite allows only ONE column per + // ALTER, so each ADD/DROP is its own statement. Booleans are + // INTEGER; tls_verify defaults 1 (TRUE). DROP COLUMN needs SQLite + // 3.35+ (Mar 2021), same as migration 20240101000017's Down. + &migrate.Migration{ + Name: "add_proxy_fields_to_cp_routes", + Version: "20240101000020", + Up: func(ctx context.Context, exec migrate.Executor) error { + for _, stmt := range []string{ + `ALTER TABLE cp_routes ADD COLUMN path_mode TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE cp_routes ADD COLUMN rewrite_redirects INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE cp_routes ADD COLUMN rewrite_cookie_path INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE cp_routes ADD COLUMN upstream_origin TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE cp_routes ADD COLUMN tls_verify INTEGER NOT NULL DEFAULT 1`, + } { + if _, err := exec.Exec(ctx, stmt); err != nil { + return err + } + } + + return nil + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + for _, stmt := range []string{ + `ALTER TABLE cp_routes DROP COLUMN path_mode`, + `ALTER TABLE cp_routes DROP COLUMN rewrite_redirects`, + `ALTER TABLE cp_routes DROP COLUMN rewrite_cookie_path`, + `ALTER TABLE cp_routes DROP COLUMN upstream_origin`, + `ALTER TABLE cp_routes DROP COLUMN tls_verify`, + } { + if _, err := exec.Exec(ctx, stmt); err != nil { + return err + } + } + + return nil + }, + }, ) } diff --git a/store/sqlite/models.go b/store/sqlite/models.go index 06a2158..afcedab 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -212,16 +212,23 @@ type domainModel struct { type routeModel struct { grove.BaseModel `grove:"table:cp_routes"` - ID string `grove:"id,pk"` - TenantID string `grove:"tenant_id,notnull"` - InstanceID string `grove:"instance_id,notnull"` - Path string `grove:"path,notnull"` - Port int `grove:"port,notnull"` - Protocol string `grove:"protocol"` - Weight int `grove:"weight"` - StripPrefix bool `grove:"strip_prefix"` - CreatedAt time.Time `grove:"created_at,notnull"` - UpdatedAt time.Time `grove:"updated_at,notnull"` + ID string `grove:"id,pk"` + TenantID string `grove:"tenant_id,notnull"` + InstanceID string `grove:"instance_id,notnull"` + Path string `grove:"path,notnull"` + Port int `grove:"port,notnull"` + Protocol string `grove:"protocol"` + Weight int `grove:"weight"` + StripPrefix bool `grove:"strip_prefix"` + + PathMode string `grove:"path_mode"` + RewriteRedirects bool `grove:"rewrite_redirects"` + RewriteCookiePath bool `grove:"rewrite_cookie_path"` + UpstreamOrigin string `grove:"upstream_origin"` + TLSVerify bool `grove:"tls_verify"` + + CreatedAt time.Time `grove:"created_at,notnull"` + UpdatedAt time.Time `grove:"updated_at,notnull"` } // certificateModel is the database model for network.Certificate. @@ -534,8 +541,15 @@ func toRouteModel(route *network.Route) *routeModel { Protocol: route.Protocol, Weight: route.Weight, StripPrefix: route.StripPrefix, - CreatedAt: route.CreatedAt, - UpdatedAt: route.UpdatedAt, + + PathMode: route.PathMode, + RewriteRedirects: route.RewriteRedirects, + RewriteCookiePath: route.RewriteCookiePath, + UpstreamOrigin: route.UpstreamOrigin, + TLSVerify: route.TLSVerify, + + CreatedAt: route.CreatedAt, + UpdatedAt: route.UpdatedAt, } } @@ -553,6 +567,12 @@ func fromRouteModel(m *routeModel) *network.Route { Protocol: m.Protocol, Weight: m.Weight, StripPrefix: m.StripPrefix, + + PathMode: m.PathMode, + RewriteRedirects: m.RewriteRedirects, + RewriteCookiePath: m.RewriteCookiePath, + UpstreamOrigin: m.UpstreamOrigin, + TLSVerify: m.TLSVerify, } }