diff --git a/network/network.go b/network/network.go index 85f3793..5fbe36c 100644 --- a/network/network.go +++ b/network/network.go @@ -29,14 +29,19 @@ type Domain struct { type Route struct { ctrlplane.Entity - TenantID string `db:"tenant_id" json:"tenant_id"` - InstanceID id.ID `db:"instance_id" json:"instance_id"` - ServiceName string `db:"service_name" json:"service_name,omitempty"` - Path string `db:"path" json:"path"` - 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"` + TenantID string `db:"tenant_id" json:"tenant_id"` + InstanceID id.ID `db:"instance_id" json:"instance_id"` + ServiceName string `db:"service_name" json:"service_name,omitempty"` + Path string `db:"path" json:"path"` + 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"` + 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..56485ef 100644 --- a/network/service.go +++ b/network/service.go @@ -52,14 +52,19 @@ type AddDomainRequest struct { // multi-service instance — leave empty to route to the instance's // Main service (the default for single-service workloads). type AddRouteRequest struct { - InstanceID id.ID `json:"instance_id" validate:"required"` - ServiceName string `json:"service_name,omitempty"` - Path string `json:"path" validate:"required"` - 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"` + InstanceID id.ID `json:"instance_id" validate:"required"` + ServiceName string `json:"service_name,omitempty"` + Path string `json:"path" validate:"required"` + Port int `json:"port" validate:"required"` + Protocol string `default:"http" json:"protocol"` + Weight int `default:"100" json:"weight"` + 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..885beb9 100644 --- a/network/service_impl.go +++ b/network/service_impl.go @@ -179,16 +179,21 @@ func (s *service) AddRoute(ctx context.Context, req AddRouteRequest) (*Route, er } route := &Route{ - Entity: ctrlplane.NewEntity(id.PrefixRoute), - TenantID: claims.TenantID, - InstanceID: req.InstanceID, - ServiceName: req.ServiceName, - Path: req.Path, - Port: req.Port, - Protocol: protocol, - Weight: weight, - StripPrefix: req.StripPrefix, - Hostname: req.Hostname, + Entity: ctrlplane.NewEntity(id.PrefixRoute), + TenantID: claims.TenantID, + InstanceID: req.InstanceID, + ServiceName: req.ServiceName, + Path: req.Path, + Port: req.Port, + Protocol: protocol, + Weight: weight, + 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 { diff --git a/store/mongo/models.go b/store/mongo/models.go index f210fb4..856b9f2 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..d137ed2 --- /dev/null +++ b/store/postgres/network_test.go @@ -0,0 +1,61 @@ +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, } }