diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 180c2c8..f57707b 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -401,6 +401,12 @@ func runDeploymentAndSyncWithImage( // ephemeral ports. _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) + maintenanceMode, basicAuthUsers := serviceAccess(db, service.ID) + maintURL := "" + if maintenanceMode { + maintURL = maintenanceURL(db) + } + deployReq := &deployment.DeploymentRequest{ ProjectID: service.ProjectID.String(), ServiceID: service.ID.String(), @@ -417,10 +423,14 @@ func runDeploymentAndSyncWithImage( HealthPath: service.HealthCheckPath, RestartPolicy: service.RestartPolicy, VolumeMounts: loadServiceVolumes(db, service.ID), + Domains: serviceDomainNames(db, service.ID, service.Domain), Resources: deployment.ResourceLimits{ MemoryBytes: parseMemoryLimit(service.Memory), CPUQuota: parseCPULimit(service.CPU), }, + Maintenance: maintenanceMode, + MaintenanceURL: maintURL, + BasicAuthUsers: basicAuthUsers, }, Trigger: deployment.TriggerConfig{ Type: req.Trigger, diff --git a/app/backend/internal/api/domains.go b/app/backend/internal/api/domains.go new file mode 100644 index 0000000..cb63360 --- /dev/null +++ b/app/backend/internal/api/domains.go @@ -0,0 +1,375 @@ +package api + +import ( + "context" + "database/sql" + "net" + "net/http" + "regexp" + "strings" + "time" + + "containr/internal/database" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" +) + +// ServiceDomain is one hostname routed to a service. services.domain stays +// the derived default; this table is the source of truth for the full set. +type ServiceDomain struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Domain string `json:"domain"` + IsDefault bool `json:"is_default"` + CertType string `json:"cert_type"` + CertStatus string `json:"cert_status"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// BasicAuthCred is a plaintext credential the server encodes to htpasswd. +type BasicAuthCred struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +var hostnameRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$`) + +func validHostname(d string) bool { + return len(d) <= 253 && hostnameRE.MatchString(d) +} + +func loadServiceDomains(db *database.DB, serviceID uuid.UUID) []ServiceDomain { + rows, err := db.Query( + `SELECT id, service_id, domain, is_default, cert_type, cert_status, last_checked_at, created_at + FROM service_domains WHERE service_id = $1 ORDER BY is_default DESC, created_at ASC`, serviceID) + if err != nil { + return nil + } + defer rows.Close() + out := []ServiceDomain{} + for rows.Next() { + var d ServiceDomain + if err := rows.Scan(&d.ID, &d.ServiceID, &d.Domain, &d.IsDefault, &d.CertType, &d.CertStatus, &d.LastCheckedAt, &d.CreatedAt); err == nil { + out = append(out, d) + } + } + return out +} + +func serviceDomainNames(db *database.DB, serviceID uuid.UUID, fallback string) []string { + domains := loadServiceDomains(db, serviceID) + out := make([]string, 0, len(domains)) + for _, d := range domains { + out = append(out, d.Domain) + } + if len(out) == 0 && fallback != "" { + out = []string{fallback} + } + return out +} + +// syncDefaultDomain writes the is_default row back to services.domain so +// legacy reads (CLI tables, public_url) keep working. +func syncDefaultDomain(db *database.DB, serviceID uuid.UUID) { + var domain string + err := db.QueryRow( + `SELECT domain FROM service_domains WHERE service_id = $1 AND is_default`, serviceID).Scan(&domain) + if err == sql.ErrNoRows { + err = db.QueryRow( + `SELECT domain FROM service_domains WHERE service_id = $1 ORDER BY created_at ASC LIMIT 1`, serviceID).Scan(&domain) + } + if err == sql.ErrNoRows { + domain = "" + } + _, _ = db.Exec(`UPDATE services SET domain = $1 WHERE id = $2`, domain, serviceID) +} + +// serviceAccess loads the maintenance/basic-auth columns for the runtime spec. +func serviceAccess(db *database.DB, serviceID uuid.UUID) (maintenance bool, basicAuth string) { + _ = db.QueryRow(`SELECT maintenance_mode, basic_auth_users FROM services WHERE id = $1`, serviceID). + Scan(&maintenance, &basicAuth) + return maintenance, basicAuth +} + +// maintenanceURL is the redirect target for maintenance mode — the public +// base URL of this API plus the maintenance page path. +func maintenanceURL(db *database.DB) string { + base := strings.TrimRight(settingValue(db, "base_url", "BASE_URL", ""), "/") + if base == "" { + return "" + } + return base + "/api/v1/maintenance" +} + +// encodeBasicAuth turns plaintext creds into Traefik's htpasswd format +// (user:bcrypt hash, comma-separated). Bcrypt is what Traefik expects. +func encodeBasicAuth(creds []BasicAuthCred) (string, error) { + pairs := make([]string, 0, len(creds)) + for _, cred := range creds { + if cred.Username == "" || cred.Password == "" { + continue + } + hash, err := bcrypt.GenerateFromPassword([]byte(cred.Password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + pairs = append(pairs, cred.Username+":"+string(hash)) + } + return strings.Join(pairs, ","), nil +} + +// basicAuthUsernames returns just the usernames for API responses — never +// the hashes. +func basicAuthUsernames(encoded string) []string { + var out []string + for _, pair := range strings.Split(encoded, ",") { + if u, _, ok := strings.Cut(pair, ":"); ok && u != "" { + out = append(out, u) + } + } + return out +} + +// serviceWriteAccess mirrors the service update handler's access check. +func serviceWriteAccess(c *gin.Context, db *database.DB, serviceID uuid.UUID) bool { + userID, _ := c.Get("user_id") + var ok bool + err := db.QueryRow( + `SELECT EXISTS(SELECT 1 FROM services s JOIN projects p ON s.project_id = p.id + WHERE s.id = $1 AND (p.owner_id = $2 OR $3::bool))`, + serviceID, userID, contextIsAdmin(c)).Scan(&ok) + return err == nil && ok +} + +func handleListServiceDomains(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available") + return + } + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID") + return + } + c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)}) +} + +func handleAddServiceDomain(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available") + return + } + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID") + return + } + if !serviceWriteAccess(c, db.(*database.DB), serviceID) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found") + return + } + var req struct { + Domain string `json:"domain" binding:"required"` + IsDefault bool `json:"is_default"` + } + if err := c.ShouldBindJSON(&req); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", err.Error()) + return + } + domain := strings.ToLower(strings.TrimSpace(req.Domain)) + if !validHostname(domain) { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid hostname") + return + } + + // First domain on a service becomes the default automatically. + var count int + _ = db.(*database.DB).QueryRow(`SELECT COUNT(*) FROM service_domains WHERE service_id = $1`, serviceID).Scan(&count) + makeDefault := req.IsDefault || count == 0 + + tx, err := db.(*database.DB).Begin() + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") + return + } + defer tx.Rollback() + if makeDefault { + if _, err := tx.Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") + return + } + } + var inserted ServiceDomain + err = tx.QueryRow( + `INSERT INTO service_domains (service_id, domain, is_default) + VALUES ($1, $2, $3) + RETURNING id, service_id, domain, is_default, cert_type, cert_status, created_at`, + serviceID, domain, makeDefault). + Scan(&inserted.ID, &inserted.ServiceID, &inserted.Domain, &inserted.IsDefault, &inserted.CertType, &inserted.CertStatus, &inserted.CreatedAt) + if err != nil { + if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") { + respondError(c, http.StatusConflict, "CONFLICT", "Domain already attached to this service") + return + } + respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") + return + } + if err := tx.Commit(); err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to add domain") + return + } + syncDefaultDomain(db.(*database.DB), serviceID) + c.JSON(http.StatusCreated, gin.H{"domain": inserted}) +} + +func handleDeleteServiceDomain(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available") + return + } + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID") + return + } + if !serviceWriteAccess(c, db.(*database.DB), serviceID) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found") + return + } + domainID, err := uuid.Parse(c.Param("domain_id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") + return + } + res, err := db.(*database.DB).Exec( + `DELETE FROM service_domains WHERE id = $1 AND service_id = $2`, domainID, serviceID) + affected, _ := res.RowsAffected() + if err != nil || affected == 0 { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") + return + } + syncDefaultDomain(db.(*database.DB), serviceID) + c.JSON(http.StatusOK, gin.H{"deleted": domainID}) +} + +func handleSetDefaultDomain(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available") + return + } + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID") + return + } + if !serviceWriteAccess(c, db.(*database.DB), serviceID) { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Service not found") + return + } + domainID, err := uuid.Parse(c.Param("domain_id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid domain ID") + return + } + res, err := db.(*database.DB).Exec( + `UPDATE service_domains SET is_default = (id = $1) WHERE service_id = $2`, + domainID, serviceID) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Failed to set default domain") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(c, http.StatusNotFound, "NOT_FOUND", "Domain not found") + return + } + syncDefaultDomain(db.(*database.DB), serviceID) + c.JSON(http.StatusOK, gin.H{"domains": loadServiceDomains(db.(*database.DB), serviceID)}) +} + +// handleCheckServiceDomains resolves each domain's DNS and compares it to +// the configured expected target (app_settings.public_ip / PUBLIC_IP, or the +// base_url host). Statuses: ok | wrong-target | pending. +func handleCheckServiceDomains(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + respondError(c, http.StatusInternalServerError, "INTERNAL", "Database connection not available") + return + } + dbh := db.(*database.DB) + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid service ID") + return + } + + expectedIP := settingValue(dbh, "public_ip", "PUBLIC_IP", "") + expectedHost := "" + if base := settingValue(dbh, "base_url", "BASE_URL", ""); base != "" { + if u := strings.TrimPrefix(strings.TrimPrefix(base, "https://"), "http://"); u != "" { + expectedHost, _, _ = strings.Cut(u, "/") + expectedHost, _, _ = strings.Cut(expectedHost, ":") + } + } + + type result struct { + Domain string `json:"domain"` + Status string `json:"status"` // ok | wrong-target | pending + Resolved []string `json:"resolved"` + } + results := []result{} + resolver := net.DefaultResolver + for _, d := range loadServiceDomains(dbh, serviceID) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 4*time.Second) + addrs, _ := resolver.LookupHost(ctx, d.Domain) + cname, _ := resolver.LookupCNAME(ctx, d.Domain) + cancel() + + r := result{Domain: d.Domain, Resolved: addrs} + cname = strings.TrimSuffix(cname, ".") + switch { + case len(addrs) == 0: + r.Status = "pending" + case expectedIP != "": + r.Status = "wrong-target" + for _, a := range addrs { + if a == expectedIP { + r.Status = "ok" + } + } + case expectedHost != "": + r.Status = "ok" // resolves; CNAME match is advisory only + if cname != "" && cname != d.Domain && cname != expectedHost { + r.Status = "wrong-target" + } + default: + r.Status = "ok" + } + results = append(results, r) + _, _ = dbh.Exec(`UPDATE service_domains SET last_checked_at = NOW() WHERE id = $1`, d.ID) + } + c.JSON(http.StatusOK, gin.H{"domains": results}) +} + +// handleMaintenancePage serves the maintenance-mode landing page. Public: +// browsers hit it via the Traefik redirect before any auth. +func handleMaintenancePage(c *gin.Context) { + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(` + +Maintenance
+
This service is under maintenance
+
Please check back shortly.
+
Containr
+
`)) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index e0e9bdc..a9eb177 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -248,6 +248,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg public.GET("/auth/bootstrap", handleAuthBootstrap) public.POST("/auth/login", handleLogin) public.POST("/auth/register", handleRegister) + public.GET("/maintenance", handleMaintenancePage) } // Read routes — public browsing. OptionalAuth resolves a session when @@ -268,6 +269,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg read.GET("/services/:id/metrics", handleGetServiceMetrics) read.GET("/services/:id/runtime", handleGetServiceRuntime) read.GET("/services/:id/deployments", handleGetDeployments) + read.GET("/services/:id/domains", handleListServiceDomains) + read.GET("/services/:id/domains/check", handleCheckServiceDomains) read.GET("/deployments", handleGetRecentDeployments) read.GET("/deployments/:id", handleGetDeployment) @@ -326,6 +329,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg authed.PUT("/services/:id", handleUpdateService) authed.DELETE("/services/:id", handleDeleteService) + authed.POST("/services/:id/domains", handleAddServiceDomain) + authed.DELETE("/services/:id/domains/:domain_id", handleDeleteServiceDomain) + authed.POST("/services/:id/domains/:domain_id/default", handleSetDefaultDomain) authed.POST("/services/:id/start", handleServiceStart) authed.POST("/services/:id/stop", handleServiceStop) authed.POST("/services/:id/restart", handleServiceRestart) diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index e74e144..d25cab7 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -54,6 +54,11 @@ func serviceRuntimeSpec(db *database.DB, service Service) (deployment.RuntimeSpe spec.NanoCPUs = parseCPULimit(service.CPU) spec.MemoryBytes = parseMemoryLimit(service.Memory) spec.Volumes = loadServiceVolumes(db, service.ID) + spec.Domains = serviceDomainNames(db, service.ID, service.Domain) + spec.Maintenance, spec.BasicAuthUsers = serviceAccess(db, service.ID) + if spec.Maintenance { + spec.MaintenanceURL = maintenanceURL(db) + } if spec.Port > 0 { env["PORT"] = strconv.Itoa(service.Port) } diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index ccf1ac5..07a6704 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -38,6 +38,9 @@ type Service struct { RestartPolicy string `json:"restart_policy" db:"restart_policy"` PublicURL string `json:"public_url,omitempty" db:"-"` // computed at read time Volumes []ServiceVolume `json:"volumes" db:"-"` // loaded lazily — stored as JSONB + Domains []ServiceDomain `json:"domains,omitempty" db:"-"` + MaintenanceMode bool `json:"maintenance_mode" db:"-"` + BasicAuth []string `json:"basic_auth,omitempty" db:"-"` // usernames only, never hashes CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -112,6 +115,8 @@ type CreateServiceRequest struct { HealthCheckPath string `json:"healthcheck_path"` RestartPolicy string `json:"restart_policy"` Volumes []ServiceVolume `json:"volumes"` + MaintenanceMode bool `json:"maintenance_mode"` + BasicAuth []BasicAuthCred `json:"basic_auth"` } // UpdateServiceRequest represents a request to update a service @@ -132,6 +137,8 @@ type UpdateServiceRequest struct { HealthCheckPath *string `json:"healthcheck_path"` RestartPolicy string `json:"restart_policy"` Volumes *[]ServiceVolume `json:"volumes"` + MaintenanceMode *bool `json:"maintenance_mode"` + BasicAuth *[]BasicAuthCred `json:"basic_auth"` } // handleGetServices retrieves all services for a project @@ -359,6 +366,26 @@ func handleCreateService(c *gin.Context) { return } + // Domains live in service_domains; services.domain is the derived default. + if req.Domain != "" && validHostname(strings.ToLower(req.Domain)) { + _, _ = db.(*database.DB).Exec( + `INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true) + ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`, + service.ID, strings.ToLower(req.Domain)) + } + if req.MaintenanceMode || len(req.BasicAuth) > 0 { + basicAuthUsers := "" + if encoded, encErr := encodeBasicAuth(req.BasicAuth); encErr == nil { + basicAuthUsers = encoded + } + _, _ = db.(*database.DB).Exec( + `UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`, + req.MaintenanceMode, basicAuthUsers, service.ID) + service.MaintenanceMode = req.MaintenanceMode + service.BasicAuth = basicAuthUsernames(basicAuthUsers) + } + service.Domains = loadServiceDomains(db.(*database.DB), service.ID) + c.JSON(http.StatusCreated, gin.H{"service": service}) } @@ -441,6 +468,10 @@ func handleGetService(c *gin.Context) { Type: v.Type, Source: v.Source, Target: v.Destination, ReadOnly: v.ReadOnly, }) } + service.Domains = loadServiceDomains(db.(*database.DB), service.ID) + maintenance, basicAuth := serviceAccess(db.(*database.DB), service.ID) + service.MaintenanceMode = maintenance + service.BasicAuth = basicAuthUsernames(basicAuth) c.JSON(http.StatusOK, gin.H{"service": service}) } @@ -555,14 +586,17 @@ func handleUpdateService(c *gin.Context) { if req.RestartPolicy != "" { existingService.RestartPolicy = req.RestartPolicy } - var volumesJSON []byte + // lib/pq sends a nil []byte as an empty bytea literal rather than NULL, + // so the COALESCE fallback needs an untyped nil when volumes aren't sent. + var volumesArg interface{} if req.Volumes != nil { if err := validateServiceVolumes(*req.Volumes); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "code": "VALIDATION"}) return } existingService.Volumes = *req.Volumes - volumesJSON, _ = json.Marshal(*req.Volumes) + volumesJSON, _ := json.Marshal(*req.Volumes) + volumesArg = volumesJSON } existingService.UpdatedAt = time.Now() @@ -573,14 +607,14 @@ func handleUpdateService(c *gin.Context) { SET name = $1, type = $2, image = $3, command = $4, environment = $5, git_repo = $6, git_branch = $7, build_path = $8, cpu = $9, memory = $10, replicas = $11, port = $12, domain = $13, healthcheck_path = $14, - restart_policy = $15, volumes = COALESCE($17, volumes), updated_at = $16 + restart_policy = $15, volumes = COALESCE($17::jsonb, volumes), updated_at = $16 WHERE id = $18`, existingService.Name, existingService.Type, existingService.Image, existingService.Command, existingService.Environment, existingService.GitRepo, existingService.GitBranch, existingService.BuildPath, existingService.CPU, existingService.Memory, existingService.Replicas, existingService.Port, existingService.Domain, existingService.HealthCheckPath, existingService.RestartPolicy, - existingService.UpdatedAt, volumesJSON, existingService.ID, + existingService.UpdatedAt, volumesArg, existingService.ID, ) if err != nil { @@ -588,6 +622,48 @@ func handleUpdateService(c *gin.Context) { return } + // Domain writes route through service_domains — services.domain is the + // derived default and gets rewritten by syncDefaultDomain. + if req.Domain != nil { + domain := strings.ToLower(strings.TrimSpace(*req.Domain)) + if domain == "" { + _, _ = db.(*database.DB).Exec(`DELETE FROM service_domains WHERE service_id = $1`, serviceID) + } else if validHostname(domain) { + _, _ = db.(*database.DB).Exec(`UPDATE service_domains SET is_default = false WHERE service_id = $1`, serviceID) + _, _ = db.(*database.DB).Exec( + `INSERT INTO service_domains (service_id, domain, is_default) VALUES ($1, $2, true) + ON CONFLICT (service_id, domain) DO UPDATE SET is_default = true`, + serviceID, domain) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid hostname", "code": "VALIDATION"}) + return + } + syncDefaultDomain(db.(*database.DB), serviceID) + _ = db.(*database.DB).QueryRow(`SELECT domain FROM services WHERE id = $1`, serviceID).Scan(&existingService.Domain) + } + + if req.MaintenanceMode != nil || req.BasicAuth != nil { + maintenance, basicAuth := serviceAccess(db.(*database.DB), serviceID) + if req.MaintenanceMode != nil { + maintenance = *req.MaintenanceMode + } + if req.BasicAuth != nil { + encoded, encErr := encodeBasicAuth(*req.BasicAuth) + if encErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to encode basic auth", "code": "INTERNAL"}) + return + } + basicAuth = encoded + } + _, _ = db.(*database.DB).Exec( + `UPDATE services SET maintenance_mode = $1, basic_auth_users = $2 WHERE id = $3`, + maintenance, basicAuth, serviceID) + existingService.MaintenanceMode = maintenance + } + existingService.Domains = loadServiceDomains(db.(*database.DB), serviceID) + _, storedAuth := serviceAccess(db.(*database.DB), serviceID) + existingService.BasicAuth = basicAuthUsernames(storedAuth) + // A replica change applies immediately when the service has live // containers; other spec fields take effect on the next deploy. // Runs through the deploy queue so it can't overlap a build. diff --git a/app/backend/internal/cli/commands/services.go b/app/backend/internal/cli/commands/services.go index d85f353..5f68f2c 100644 --- a/app/backend/internal/cli/commands/services.go +++ b/app/backend/internal/cli/commands/services.go @@ -179,6 +179,21 @@ var servicesUpdateCmd = &cobra.Command{ } body["volumes"] = volumes } + if cmd.Flags().Changed("maintenance") { + v, _ := cmd.Flags().GetString("maintenance") + body["maintenance_mode"] = v == "on" || v == "true" + } + if cmd.Flags().Changed("basic-auth") { + creds := []map[string]interface{}{} + pairs, _ := cmd.Flags().GetStringArray("basic-auth") + for _, p := range pairs { + user, pass, ok := strings.Cut(p, ":") + if ok { + creds = append(creds, map[string]interface{}{"username": user, "password": pass}) + } + } + body["basic_auth"] = creds + } if len(body) == 0 { return &APIError{Message: "nothing to update — pass a flag", ExitCode: ExitError} } @@ -220,6 +235,141 @@ func volumeFlagValues(cmd *cobra.Command, flags ...string) []map[string]interfac return out } +var servicesDomainsCmd = &cobra.Command{ + Use: "domains", + Short: "Manage service domains", +} + +var domainsListCmd = &cobra.Command{ + Use: "list ", + Short: "List domains attached to a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/services/"+args[0]+"/domains", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "domains") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, d := range items { + def := "" + if b, ok := d["is_default"].(bool); ok && b { + def = "*" + } + rows = append(rows, []string{str(d, "id"), str(d, "domain"), def, str(d, "cert_status")}) + } + printRows(data, []string{"ID", "DOMAIN", "DEFAULT", "CERT"}, rows) + return nil + }, +} + +var domainsAddCmd = &cobra.Command{ + Use: "add ", + Short: "Attach a domain to a service", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + def, _ := cmd.Flags().GetBool("default") + data, err := c.Do("POST", "/services/"+args[0]+"/domains", map[string]interface{}{ + "domain": args[1], "is_default": def, + }) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Added domain %s\n", args[1]) + return nil + }, +} + +var domainsRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Detach a domain", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + if _, err := c.Do("DELETE", fmt.Sprintf("/services/%s/domains/%s", args[0], args[1]), nil); err != nil { + return err + } + if JSONMode() { + return PrintJSON(map[string]string{"status": "deleted", "id": args[1]}) + } + fmt.Printf("Removed domain %s\n", args[1]) + return nil + }, +} + +var domainsDefaultCmd = &cobra.Command{ + Use: "default ", + Short: "Set the default domain", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("POST", fmt.Sprintf("/services/%s/domains/%s/default", args[0], args[1]), map[string]interface{}{}) + if err != nil { + return err + } + if JSONMode() { + PrintRaw(data) + return nil + } + fmt.Printf("Default domain updated\n") + return nil + }, +} + +var domainsCheckCmd = &cobra.Command{ + Use: "check ", + Short: "DNS preflight for all service domains", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + data, err := c.Do("GET", "/services/"+args[0]+"/domains/check", nil) + if err != nil { + return err + } + items, err := unwrapList(data, "domains") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, d := range items { + resolved, _ := d["resolved"].([]interface{}) + addrs := make([]string, 0, len(resolved)) + for _, a := range resolved { + if s, ok := a.(string); ok { + addrs = append(addrs, s) + } + } + rows = append(rows, []string{str(d, "domain"), str(d, "status"), strings.Join(addrs, ", ")}) + } + printRows(data, []string{"DOMAIN", "STATUS", "RESOLVED"}, rows) + return nil + }, +} + // serviceAction returns a RunE for the simple POST action endpoints. func serviceAction(verb string) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { @@ -271,6 +421,8 @@ func init() { uf.StringArray("volume", nil, "named volume mount name:path[:ro] (repeatable, replaces all mounts)") uf.StringArray("bind", nil, "bind mount src:path[:ro] (repeatable)") uf.Bool("clear-volumes", false, "remove all volume mounts") + uf.String("maintenance", "", "maintenance mode: on|off") + uf.StringArray("basic-auth", nil, "basic-auth credential user:password (repeatable, replaces all)") for _, a := range []string{"start", "stop", "restart", "redeploy"} { verb := a @@ -281,5 +433,7 @@ func init() { RunE: serviceAction(verb), }) } - ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd) + domainsAddCmd.Flags().Bool("default", false, "set as the default domain") + servicesDomainsCmd.AddCommand(domainsListCmd, domainsAddCmd, domainsRemoveCmd, domainsDefaultCmd, domainsCheckCmd) + ServicesCmd.AddCommand(servicesListCmd, servicesGetCmd, servicesCreateCmd, servicesUpdateCmd, servicesDeleteCmd, servicesDomainsCmd) } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 9f0b1ef..585c59c 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -202,6 +202,19 @@ type Service struct { RestartPolicy string `json:"restart_policy"` PublishedPort int32 `json:"published_port"` Volumes json.RawMessage `json:"volumes"` + MaintenanceMode bool `json:"maintenance_mode"` + BasicAuthUsers string `json:"basic_auth_users"` +} + +type ServiceDomain struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Domain string `json:"domain"` + IsDefault bool `json:"is_default"` + CertType string `json:"cert_type"` + CertStatus string `json:"cert_status"` + LastCheckedAt sql.NullTime `json:"last_checked_at"` + CreatedAt time.Time `json:"created_at"` } type ServiceTemplate struct { diff --git a/app/backend/internal/deployment/engine.go b/app/backend/internal/deployment/engine.go index 7dfdbc1..4f1d3df 100644 --- a/app/backend/internal/deployment/engine.go +++ b/app/backend/internal/deployment/engine.go @@ -57,6 +57,11 @@ type ServiceConfig struct { PublicPort int32 `json:"public_port,omitempty"` // Domain is a public hostname routed to PublicPort via Traefik. Domain string `json:"domain,omitempty"` + // Domains is the full hostname set (includes Domain when set). + Domains []string `json:"domains,omitempty"` + Maintenance bool `json:"maintenance,omitempty"` + MaintenanceURL string `json:"maintenance_url,omitempty"` + BasicAuthUsers string `json:"basic_auth_users,omitempty"` // HealthPath is an HTTP path probed on PublicPort for container health. HealthPath string `json:"health_path,omitempty"` // PublishedPort is the host port bound by the previous deployment; replica 0 @@ -312,21 +317,25 @@ func (de *DeploymentEngine) deployService(ctx context.Context, deployment *Deplo cfg := deployment.Config spec := RuntimeSpec{ - ProjectID: deployment.ProjectID, - ServiceID: deployment.ServiceID, - Name: cfg.Name, - Image: deployment.ImageName, - Command: cfg.Command, - Env: cfg.Environment, - Replicas: cfg.Replicas, - Port: cfg.PublicPort, - PublishedPort: cfg.PublishedPort, - Domain: cfg.Domain, - HealthPath: cfg.HealthPath, - RestartPolicy: cfg.RestartPolicy, - MemoryBytes: cfg.Resources.MemoryBytes, - NanoCPUs: cfg.Resources.CPUQuota, - Volumes: cfg.VolumeMounts, + ProjectID: deployment.ProjectID, + ServiceID: deployment.ServiceID, + Name: cfg.Name, + Image: deployment.ImageName, + Command: cfg.Command, + Env: cfg.Environment, + Replicas: cfg.Replicas, + Port: cfg.PublicPort, + PublishedPort: cfg.PublishedPort, + Domain: cfg.Domain, + HealthPath: cfg.HealthPath, + RestartPolicy: cfg.RestartPolicy, + MemoryBytes: cfg.Resources.MemoryBytes, + NanoCPUs: cfg.Resources.CPUQuota, + Volumes: cfg.VolumeMounts, + Domains: cfg.Domains, + Maintenance: cfg.Maintenance, + MaintenanceURL: cfg.MaintenanceURL, + BasicAuthUsers: cfg.BasicAuthUsers, } for _, pm := range cfg.PortMappings { if spec.Port == 0 { diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index 6bd0819..3373c79 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -34,21 +34,25 @@ const ( // RuntimeSpec is everything needed to run a service's containers. type RuntimeSpec struct { - ProjectID string - ServiceID string - Name string // DNS alias on the project network - Image string - Command []string - Env map[string]string // already resolved - Replicas int - Port int32 // container port to expose publicly; 0 = none - PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral - Domain string // public hostname routed via Traefik; empty = none - HealthPath string // http path probed on Port for container healthcheck - RestartPolicy string - MemoryBytes int64 - NanoCPUs int64 - Volumes []VolumeMount // applied to every replica + ProjectID string + ServiceID string + Name string // DNS alias on the project network + Image string + Command []string + Env map[string]string // already resolved + Replicas int + Port int32 // container port to expose publicly; 0 = none + PublishedPort int32 // preferred host port for replica 0; 0 = pick ephemeral + Domain string // public hostname routed via Traefik; empty = none + HealthPath string // http path probed on Port for container healthcheck + RestartPolicy string + MemoryBytes int64 + NanoCPUs int64 + Volumes []VolumeMount // applied to every replica + Domains []string // all public hostnames; Domain is the default + Maintenance bool // redirect traffic to MaintenanceURL + MaintenanceURL string // absolute URL; empty = serve empty response + BasicAuthUsers string // htpasswd-format user:hash pairs, comma-separated } // RuntimeContainer describes one live replica. @@ -227,12 +231,45 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, projectNet: {Aliases: []string{spec.Name}}, } if edgeNet != "" { + router := "svc-" + spec.ServiceID[:8] + domains := spec.Domains + if len(domains) == 0 && spec.Domain != "" { + domains = []string{spec.Domain} + } + sort.Strings(domains) + rules := make([]string, 0, len(domains)) + for _, d := range domains { + rules = append(rules, "Host(`"+d+"`)") + } endpoints[edgeNet] = &network.EndpointSettings{} labels["traefik.enable"] = "true" labels["traefik.docker.network"] = edgeNet - labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".rule"] = "Host(`" + spec.Domain + "`)" - labels["traefik.http.routers.svc-"+spec.ServiceID[:8]+".entrypoints"] = "web" - labels["traefik.http.services.svc-"+spec.ServiceID[:8]+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) + labels["traefik.http.routers."+router+".rule"] = strings.Join(rules, " || ") + labels["traefik.http.routers."+router+".entrypoints"] = "web" + labels["traefik.http.services."+router+".loadbalancer.server.port"] = fmt.Sprintf("%d", spec.Port) + + var middlewares []string + if spec.Maintenance { + if spec.MaintenanceURL != "" { + mw := router + "-maint" + labels["traefik.http.middlewares."+mw+".redirectregex.regex"] = "^https?://[^/]+/.*" + labels["traefik.http.middlewares."+mw+".redirectregex.replacement"] = spec.MaintenanceURL + labels["traefik.http.middlewares."+mw+".redirectregex.permanent"] = "false" + middlewares = append(middlewares, mw) + } else { + // No redirect target configured — take the site offline with + // empty responses instead of leaking traffic to the app. + labels["traefik.http.routers."+router+".service"] = "noop@internal" + } + } + if spec.BasicAuthUsers != "" { + mw := router + "-auth" + labels["traefik.http.middlewares."+mw+".basicauth.users"] = spec.BasicAuthUsers + middlewares = append(middlewares, mw) + } + if len(middlewares) > 0 { + labels["traefik.http.routers."+router+".middlewares"] = strings.Join(middlewares, ",") + } } cfg := docker.ContainerConfig{ @@ -490,8 +527,12 @@ func specHash(spec RuntimeSpec) string { Port int32 MemoryBytes, NanoCPUs int64 Volumes []VolumeMount + Domains []string + Maintenance bool + BasicAuth string }{spec.Image, spec.Name, spec.Domain, spec.HealthPath, spec.RestartPolicy, - spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes}) + spec.Command, spec.Env, spec.Port, spec.MemoryBytes, spec.NanoCPUs, spec.Volumes, + spec.Domains, spec.Maintenance, spec.BasicAuthUsers}) sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:8]) } diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index b7797d8..54b1979 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -61,7 +61,14 @@ func init() { register(spec{"containr_services_redeploy", "Rebuild and redeploy a service", "POST", "/services/{id}/redeploy", []string{"id"}, nil, false}) register(spec{"containr_services_logs", "Get service runtime logs. Args: id, tail (default 100).", "GET", "/services/{id}/logs", []string{"id"}, nil, false}) register(spec{"containr_services_exec", "Run a one-off command in the service container (30s ceiling). Args: id, command (required).", "POST", "/services/{id}/exec", []string{"id"}, []string{"command"}, false}) - register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear.", "PUT", "/services/{id}", []string{"id"}, nil, false}) + register(spec{"containr_services_update", "Update a service. Args: id, name, image, command, domain, restart_policy, healthcheck_path, cpu, memory, replicas, port, volumes = [{type: volume|bind, source, target, read_only}] — volumes replaces the whole mount list; pass [] to clear. maintenance_mode (bool), basic_auth = [{username, password}] — replaces all creds; [] clears.", "PUT", "/services/{id}", []string{"id"}, nil, false}) + + // Domains + register(spec{"containr_domains_list", "List domains attached to a service", "GET", "/services/{id}/domains", []string{"id"}, nil, false}) + register(spec{"containr_domains_add", "Attach a domain to a service. Args: id, domain (required), is_default.", "POST", "/services/{id}/domains", []string{"id"}, []string{"domain", "is_default"}, false}) + register(spec{"containr_domains_remove", "Detach a domain. Args: id (service), domain_id.", "DELETE", "/services/{id}/domains/{domain_id}", []string{"id", "domain_id"}, nil, false}) + register(spec{"containr_domains_default", "Set the default domain. Args: id (service), domain_id.", "POST", "/services/{id}/domains/{domain_id}/default", []string{"id", "domain_id"}, nil, false}) + register(spec{"containr_domains_check", "DNS preflight for all service domains — returns ok|wrong-target|pending per domain", "GET", "/services/{id}/domains/check", []string{"id"}, nil, false}) // Docker volumes (admin) register(spec{"containr_volumes_list", "List docker volumes on the node with in-use flags (admin)", "GET", "/admin/volumes", nil, nil, false}) diff --git a/app/backend/migrations_goose/20261010000000_service_domains.sql b/app/backend/migrations_goose/20261010000000_service_domains.sql new file mode 100644 index 0000000..16289c9 --- /dev/null +++ b/app/backend/migrations_goose/20261010000000_service_domains.sql @@ -0,0 +1,30 @@ +-- +goose Up +-- Multi-domain support. services.domain stays as the derived default for +-- compatibility; service_domains is the source of truth for the full +-- hostname set. Backfills existing services.domain rows as defaults. +CREATE TABLE IF NOT EXISTS service_domains ( + id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY, + service_id uuid NOT NULL REFERENCES services(id) ON DELETE CASCADE, + domain varchar(255) NOT NULL, + is_default boolean NOT NULL DEFAULT false, + cert_type varchar(32) NOT NULL DEFAULT 'letsencrypt', + cert_status varchar(32) NOT NULL DEFAULT 'pending', + last_checked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (service_id, domain) +); +CREATE INDEX IF NOT EXISTS idx_service_domains_service ON service_domains(service_id); + +INSERT INTO service_domains (service_id, domain, is_default) + SELECT id, domain, true FROM services WHERE domain <> '' AND domain IS NOT NULL + ON CONFLICT (service_id, domain) DO NOTHING; + +-- Access control: maintenance mode redirects to a platform page; basic auth +-- gates the route behind htpasswd-format user:hash pairs (Traefik middleware). +ALTER TABLE services ADD COLUMN IF NOT EXISTS maintenance_mode boolean NOT NULL DEFAULT false; +ALTER TABLE services ADD COLUMN IF NOT EXISTS basic_auth_users text NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE services DROP COLUMN IF EXISTS maintenance_mode; +ALTER TABLE services DROP COLUMN IF EXISTS basic_auth_users; +DROP TABLE IF EXISTS service_domains; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index d07b9e2..fd208cc 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -59,7 +59,21 @@ CREATE TABLE services ( healthcheck_path VARCHAR(255) NOT NULL DEFAULT '', restart_policy VARCHAR(50) NOT NULL DEFAULT 'unless-stopped', published_port INTEGER NOT NULL DEFAULT 0, - volumes JSONB NOT NULL DEFAULT '[]'::jsonb + volumes JSONB NOT NULL DEFAULT '[]'::jsonb, + maintenance_mode BOOLEAN NOT NULL DEFAULT false, + basic_auth_users TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE service_domains ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, + domain VARCHAR(255) NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT false, + cert_type VARCHAR(32) NOT NULL DEFAULT 'letsencrypt', + cert_status VARCHAR(32) NOT NULL DEFAULT 'pending', + last_checked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (service_id, domain) ); CREATE TABLE deployments ( diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 31b26d6..72ddc95 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -36,6 +36,13 @@ import { getServiceRuntime, type CronJobEntity, type ServiceVolume, + type ServiceDomain, + type DomainCheckResult, + listServiceDomains, + addServiceDomain, + removeServiceDomain, + setDefaultServiceDomain, + checkServiceDomains, } from '@/lib/api-client'; import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data'; import { useDemoMode } from '@/lib/demo-mode'; @@ -438,6 +445,23 @@ export function ServiceDetailPage() { port: string; domain: string; healthcheckPath: string; restartPolicy: string; replicas: string; } | null>(null); const [volumesForm, setVolumesForm] = useState(null); + const [domainInput, setDomainInput] = useState(''); + const [domainChecks, setDomainChecks] = useState | null>(null); + const [accessForm, setAccessForm] = useState<{ maintenance: boolean; basicAuth: string } | null>(null); + + const domainsQuery = useQuery({ + queryKey: ['service-domains', serviceId], + queryFn: () => listServiceDomains(serviceId), + enabled: Boolean(serviceId) && !isDemoMode, + }); + + const domainMutation = useMutation({ + mutationFn: (fn: () => Promise) => fn(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['service-domains', serviceId] }); + invalidateService(); + }, + }); const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data; const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data; @@ -2198,6 +2222,174 @@ export function ServiceDetailPage() { )} + {!isDemoMode && ( +
+
+

Domains

+ +
+ +
+ {(domainsQuery.data ?? []).length === 0 && !domainsQuery.isLoading && ( +

No domains attached.

+ )} + {(domainsQuery.data ?? []).map((d: ServiceDomain) => { + const check = domainChecks?.[d.domain]; + return ( +
+ {d.domain} + {d.is_default && default} + {check && ( + + {check.status} + + )} + + {!d.is_default && ( + + )} + +
+ ); + })} +
+ +
+ setDomainInput(e.target.value)} + placeholder="app.example.com" + className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm mono text-[var(--text-primary)]" + /> + +
+ {domainMutation.isError && ( +

{(domainMutation.error as Error).message}

+ )} +

Point the domain at this node (A record or CNAME), then Check DNS. Applies on next deploy or redeploy.

+
+ )} + + {!isDemoMode && ( +
+
+

Access

+ {accessForm === null && ( + + )} +
+ + {accessForm === null ? ( +
+
+

Maintenance mode

+

{service.maintenanceMode ? 'on' : 'off'}

+
+
+

Basic auth

+

+ {(service.basicAuth ?? []).length > 0 ? (service.basicAuth ?? []).join(', ') : 'off'} +

+
+
+ ) : ( +
+ +
+ +