package handlers import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/pkg/api" ) // DomainAliasRequest is the request body for POST /projects/{id}/domains. type DomainAliasRequest struct { Domain string `json:"domain"` // The domain to add (e.g., "www.threesix.ai") Type string `json:"type,omitempty"` // "A" or "CNAME" (default: "A") Proxied bool `json:"proxied,omitempty"` // Cloudflare proxy (default: false) Content string `json:"content,omitempty"` // Target (default: cluster IP for A records) } // DomainAliasResponse is the response for domain alias operations. type DomainAliasResponse struct { Domain string `json:"domain"` Type string `json:"type"` Content string `json:"content"` TTL int `json:"ttl"` Proxied bool `json:"proxied"` } // ListDomains returns all DNS records associated with a project. // GET /projects/{id}/domains func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if h.dns == nil { api.WriteInternalError(w, r, "DNS provider not configured") return } // List all A records and find ones matching this project aRecords, err := h.dns.ListRecords(ctx, "A") if err != nil { api.WriteInternalError(w, r, "failed to list DNS records") return } // Also list CNAME records cnameRecords, err := h.dns.ListRecords(ctx, "CNAME") if err != nil { api.WriteInternalError(w, r, "failed to list DNS records") return } // Filter records that belong to this project: // - Primary: {projectID}.{defaultDomain} // - Aliases: any record pointing to the cluster IP or the project's primary domain primaryDomain := projectID + "." + h.defaultDomain var domains []DomainAliasResponse for _, rec := range aRecords { name := rec.Name // Normalize: if name matches the project's subdomain or points to our cluster IP if name == primaryDomain || (rec.Content == h.clusterIP && isProjectDomain(name, projectID, h.defaultDomain)) { domains = append(domains, DomainAliasResponse{ Domain: name, Type: rec.Type, Content: rec.Content, TTL: rec.TTL, Proxied: rec.Proxied, }) } } for _, rec := range cnameRecords { // CNAME records pointing to the project's primary domain if rec.Content == primaryDomain { domains = append(domains, DomainAliasResponse{ Domain: rec.Name, Type: rec.Type, Content: rec.Content, TTL: rec.TTL, Proxied: rec.Proxied, }) } } api.WriteSuccess(w, r, map[string]any{ "project_id": projectID, "domains": domains, "total": len(domains), }) } // AddDomainAlias adds a DNS alias for a project. // POST /projects/{id}/domains func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if h.dns == nil { api.WriteInternalError(w, r, "DNS provider not configured") return } var req DomainAliasRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { api.WriteBadRequest(w, r, "invalid request body") return } if req.Domain == "" { api.WriteBadRequest(w, r, "domain is required") return } // Default record type is A recordType := strings.ToUpper(req.Type) if recordType == "" { recordType = "A" } if recordType != "A" && recordType != "CNAME" { api.WriteBadRequest(w, r, "type must be A or CNAME") return } // Determine content content := req.Content if content == "" { switch recordType { case "A": if h.clusterIP == "" { api.WriteBadRequest(w, r, "cluster IP not configured and no content provided") return } content = h.clusterIP case "CNAME": // Default CNAME target is the project's primary domain content = projectID + "." + h.defaultDomain } } // Determine the DNS name // If domain is a full FQDN under our zone, extract the subdomain for the API call dnsName := req.Domain if isSubdomain(req.Domain, h.defaultDomain) { dnsName = getSubdomain(req.Domain, h.defaultDomain) } record, err := h.dns.CreateRecord(ctx, domain.DNSRecord{ Type: recordType, Name: dnsName, Content: content, TTL: 1, Proxied: req.Proxied, }) if err != nil { api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err)) return } note := "Domain alias configured" if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" { note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP) } api.WriteCreated(w, r, map[string]any{ "project": projectID, "domain": record.Name, "type": record.Type, "content": record.Content, "status": "configured", "note": note, }) } // RemoveDomainAlias removes a DNS alias from a project. // DELETE /projects/{id}/domains/{domain} func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") aliasDomain := chi.URLParam(r, "domain") ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if err := validateProjectID(projectID); err != nil { api.WriteBadRequest(w, r, err.Error()) return } if aliasDomain == "" { api.WriteBadRequest(w, r, "domain path parameter is required") return } if h.dns == nil { api.WriteInternalError(w, r, "DNS provider not configured") return } // Prevent deleting the project's primary domain through this endpoint primaryDomain := projectID + "." + h.defaultDomain if aliasDomain == primaryDomain { api.WriteBadRequest(w, r, "cannot remove primary project domain through alias endpoint; use DELETE /project/{name} instead") return } // Check if the record exists before attempting deletion dnsName := aliasDomain if isSubdomain(aliasDomain, h.defaultDomain) { dnsName = getSubdomain(aliasDomain, h.defaultDomain) } // Check both A and CNAME records aRecord, _ := h.dns.FindRecord(ctx, "A", dnsName) cnameRecord, _ := h.dns.FindRecord(ctx, "CNAME", dnsName) if aRecord == nil && cnameRecord == nil { api.WriteNotFound(w, r, fmt.Sprintf("no DNS record found for %s", aliasDomain)) return } // Delete whichever record(s) exist if aRecord != nil { _ = h.dns.DeleteRecordByName(ctx, "A", dnsName) } if cnameRecord != nil { _ = h.dns.DeleteRecordByName(ctx, "CNAME", dnsName) } api.WriteSuccess(w, r, map[string]string{ "project": projectID, "domain": aliasDomain, "status": "removed", }) } // isProjectDomain checks if a DNS name is associated with a project. // It matches: {projectID}.{baseDomain} or any subdomain pattern containing the project ID. func isProjectDomain(name, projectID, baseDomain string) bool { // Exact match: landing.threesix.ai if name == projectID+"."+baseDomain { return true } return false }