mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-12 20:30:05 +01:00
Refactor routing logic (Chi can be a pain when you're managing large sets of routes, but its one of the better options when considering a potential gRPC future)
Upgrade API Generation to fully support OAS3.1
Update swagger interface to RapiDoc - the old swagger interface doesnt support OAS3.1 yet
Docs are now embedded as part of the UI - once logged in they pick up the cookies and org id from what gets set by the UI, but you can override it
Other updates include better portability of the db-studio
Signed-off-by: allanice001 <allanice001@gmail.com>
798 lines
24 KiB
Go
798 lines
24 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/glueops/autoglue/internal/api/httpmiddleware"
|
|
"github.com/glueops/autoglue/internal/handlers/dto"
|
|
"github.com/glueops/autoglue/internal/models"
|
|
"github.com/glueops/autoglue/internal/utils"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ---------- Helpers ----------
|
|
|
|
func normLowerNoDot(s string) string {
|
|
s = strings.TrimSpace(strings.ToLower(s))
|
|
return strings.TrimSuffix(s, ".")
|
|
}
|
|
|
|
func fqdn(domain string, rel string) string {
|
|
d := normLowerNoDot(domain)
|
|
r := normLowerNoDot(rel)
|
|
if r == "" || r == "@" {
|
|
return d
|
|
}
|
|
return r + "." + d
|
|
}
|
|
|
|
func canonicalJSONAny(v any) ([]byte, error) {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var anyv any
|
|
if err := json.Unmarshal(b, &anyv); err != nil {
|
|
return nil, err
|
|
}
|
|
return marshalSortedDNS(anyv)
|
|
}
|
|
|
|
func marshalSortedDNS(v any) ([]byte, error) {
|
|
switch vv := v.(type) {
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(vv))
|
|
for k := range vv {
|
|
keys = append(keys, k)
|
|
}
|
|
sortStrings(keys)
|
|
var buf bytes.Buffer
|
|
buf.WriteByte('{')
|
|
for i, k := range keys {
|
|
if i > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
kb, _ := json.Marshal(k)
|
|
buf.Write(kb)
|
|
buf.WriteByte(':')
|
|
b, err := marshalSortedDNS(vv[k])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(b)
|
|
}
|
|
buf.WriteByte('}')
|
|
return buf.Bytes(), nil
|
|
case []any:
|
|
var buf bytes.Buffer
|
|
buf.WriteByte('[')
|
|
for i, e := range vv {
|
|
if i > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
b, err := marshalSortedDNS(e)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(b)
|
|
}
|
|
buf.WriteByte(']')
|
|
return buf.Bytes(), nil
|
|
default:
|
|
return json.Marshal(v)
|
|
}
|
|
}
|
|
|
|
func sortStrings(a []string) {
|
|
for i := 0; i < len(a); i++ {
|
|
for j := i + 1; j < len(a); j++ {
|
|
if a[j] < a[i] {
|
|
a[i], a[j] = a[j], a[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func sha256HexBytes(b []byte) string {
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
/* Fingerprint (provider-agnostic) */
|
|
type desiredRecord struct {
|
|
ZoneID string `json:"zone_id"`
|
|
FQDN string `json:"fqdn"`
|
|
Type string `json:"type"`
|
|
TTL *int `json:"ttl,omitempty"`
|
|
Values []string `json:"values,omitempty"`
|
|
}
|
|
|
|
func computeFingerprint(zoneID, fqdn, typ string, ttl *int, values datatypes.JSON) (string, error) {
|
|
var vals []string
|
|
if len(values) > 0 && string(values) != "null" {
|
|
if err := json.Unmarshal(values, &vals); err != nil {
|
|
return "", err
|
|
}
|
|
sortStrings(vals)
|
|
}
|
|
payload := &desiredRecord{
|
|
ZoneID: zoneID, FQDN: fqdn, Type: strings.ToUpper(typ), TTL: ttl, Values: vals,
|
|
}
|
|
can, err := canonicalJSONAny(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return sha256HexBytes(can), nil
|
|
}
|
|
|
|
func mustSameOrgDomainWithCredential(db *gorm.DB, orgID uuid.UUID, credID uuid.UUID) error {
|
|
var cred models.Credential
|
|
if err := db.Where("id = ? AND organization_id = ?", credID, orgID).First(&cred).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return fmt.Errorf("credential not found or belongs to different org")
|
|
}
|
|
return err
|
|
}
|
|
if cred.Provider != "aws" || cred.ScopeKind != "service" {
|
|
return fmt.Errorf("credential must be AWS Route 53 service scoped")
|
|
}
|
|
var scope map[string]any
|
|
if err := json.Unmarshal(cred.Scope, &scope); err != nil {
|
|
return fmt.Errorf("credential scope invalid json: %w", err)
|
|
}
|
|
if strings.ToLower(fmt.Sprint(scope["service"])) != "route53" {
|
|
return fmt.Errorf("credential scope.service must be route53")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------- Domain Handlers ----------
|
|
|
|
// ListDomains godoc
|
|
//
|
|
// @ID ListDomains
|
|
// @Summary List domains (org scoped)
|
|
// @Description Returns domains for X-Org-ID. Filters: `domain_name`, `status`, `q` (contains).
|
|
// @Tags DNS
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param domain_name query string false "Exact domain name (lowercase, no trailing dot)"
|
|
// @Param status query string false "pending|provisioning|ready|failed"
|
|
// @Param q query string false "Domain contains (case-insensitive)"
|
|
// @Success 200 {array} dto.DomainResponse
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 500 {string} string "db error"
|
|
// @Router /dns/domains [get]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func ListDomains(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
q := db.Model(&models.Domain{}).Where("organization_id = ?", orgID)
|
|
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("domain_name"))); v != "" {
|
|
q = q.Where("LOWER(domain_name) = ?", v)
|
|
}
|
|
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
|
|
q = q.Where("status = ?", v)
|
|
}
|
|
if needle := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("q"))); needle != "" {
|
|
q = q.Where("LOWER(domain_name) LIKE ?", "%"+needle+"%")
|
|
}
|
|
|
|
var rows []models.Domain
|
|
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
|
return
|
|
}
|
|
out := make([]dto.DomainResponse, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, domainOut(&rows[i]))
|
|
}
|
|
utils.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// GetDomain godoc
|
|
//
|
|
// @ID GetDomain
|
|
// @Summary Get a domain (org scoped)
|
|
// @Tags DNS
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param id path string true "Domain ID (UUID)"
|
|
// @Success 200 {object} dto.DomainResponse
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Router /dns/domains/{id} [get]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func GetDomain(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
|
return
|
|
}
|
|
var row models.Domain
|
|
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
|
return
|
|
}
|
|
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
|
|
}
|
|
}
|
|
|
|
// CreateDomain godoc
|
|
//
|
|
// @ID CreateDomain
|
|
// @Summary Create a domain (org scoped)
|
|
// @Description Creates a domain bound to a Route 53 scoped credential. Archer will backfill ZoneID if omitted.
|
|
// @Tags DNS
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param body body dto.CreateDomainRequest true "Domain payload"
|
|
// @Success 201 {object} dto.DomainResponse
|
|
// @Failure 400 {string} string "validation error"
|
|
// @Failure 401 {string} string "Unauthorized"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 500 {string} string "db error"
|
|
// @Router /dns/domains [post]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func CreateDomain(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
var in dto.CreateDomainRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
if err := dto.DNSValidate(in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
credID, _ := uuid.Parse(in.CredentialID)
|
|
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
|
|
return
|
|
}
|
|
|
|
row := &models.Domain{
|
|
OrganizationID: orgID,
|
|
DomainName: normLowerNoDot(in.DomainName),
|
|
ZoneID: strings.TrimSpace(in.ZoneID),
|
|
Status: "pending",
|
|
LastError: "",
|
|
CredentialID: credID,
|
|
}
|
|
if err := db.Create(row).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
utils.WriteJSON(w, http.StatusCreated, domainOut(row))
|
|
}
|
|
}
|
|
|
|
// UpdateDomain godoc
|
|
//
|
|
// @ID UpdateDomain
|
|
// @Summary Update a domain (org scoped)
|
|
// @Tags DNS
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param id path string true "Domain ID (UUID)"
|
|
// @Param body body dto.UpdateDomainRequest true "Fields to update"
|
|
// @Success 200 {object} dto.DomainResponse
|
|
// @Failure 400 {string} string "validation error"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Router /dns/domains/{id} [patch]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func UpdateDomain(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
|
return
|
|
}
|
|
var row models.Domain
|
|
if err := db.Where("organization_id = ? AND id = ?", orgID, id).First(&row).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
var in dto.UpdateDomainRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
if err := dto.DNSValidate(in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
if in.DomainName != nil {
|
|
row.DomainName = normLowerNoDot(*in.DomainName)
|
|
}
|
|
if in.CredentialID != nil {
|
|
credID, _ := uuid.Parse(*in.CredentialID)
|
|
if err := mustSameOrgDomainWithCredential(db, orgID, credID); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "invalid_credential", err.Error())
|
|
return
|
|
}
|
|
row.CredentialID = credID
|
|
row.Status = "pending"
|
|
row.LastError = ""
|
|
}
|
|
if in.ZoneID != nil {
|
|
row.ZoneID = strings.TrimSpace(*in.ZoneID)
|
|
}
|
|
if in.Status != nil {
|
|
row.Status = *in.Status
|
|
if row.Status == "pending" {
|
|
row.LastError = ""
|
|
}
|
|
}
|
|
if err := db.Save(&row).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
utils.WriteJSON(w, http.StatusOK, domainOut(&row))
|
|
}
|
|
}
|
|
|
|
// DeleteDomain godoc
|
|
//
|
|
// @ID DeleteDomain
|
|
// @Summary Delete a domain
|
|
// @Tags DNS
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param id path string true "Domain ID (UUID)"
|
|
// @Success 204
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Router /dns/domains/{id} [delete]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func DeleteDomain(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
|
return
|
|
}
|
|
res := db.Where("organization_id = ? AND id = ?", orgID, id).Delete(&models.Domain{})
|
|
if res.Error != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// ---------- Record Set Handlers ----------
|
|
|
|
// ListRecordSets godoc
|
|
//
|
|
// @ID ListRecordSets
|
|
// @Summary List record sets for a domain
|
|
// @Description Filters: `name`, `type`, `status`.
|
|
// @Tags DNS
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param domain_id path string true "Domain ID (UUID)"
|
|
// @Param name query string false "Exact relative name or FQDN (server normalizes)"
|
|
// @Param type query string false "RR type (A, AAAA, CNAME, TXT, MX, NS, SRV, CAA)"
|
|
// @Param status query string false "pending|provisioning|ready|failed"
|
|
// @Success 200 {array} dto.RecordSetResponse
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "domain not found"
|
|
// @Router /dns/domains/{domain_id}/records [get]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func ListRecordSets(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
|
|
if err != nil {
|
|
log.Info().Msg(err.Error())
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID:")
|
|
return
|
|
}
|
|
var domain models.Domain
|
|
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
|
return
|
|
}
|
|
|
|
q := db.Model(&models.RecordSet{}).Where("domain_id = ?", did)
|
|
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("name"))); v != "" {
|
|
dn := strings.ToLower(domain.DomainName)
|
|
rel := v
|
|
// normalize apex or FQDN into relative
|
|
if v == dn || v == dn+"." {
|
|
rel = ""
|
|
} else {
|
|
rel = strings.TrimSuffix(v, "."+dn)
|
|
rel = normLowerNoDot(rel)
|
|
}
|
|
q = q.Where("LOWER(name) = ?", rel)
|
|
}
|
|
if v := strings.TrimSpace(strings.ToUpper(r.URL.Query().Get("type"))); v != "" {
|
|
q = q.Where("type = ?", v)
|
|
}
|
|
if v := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("status"))); v != "" {
|
|
q = q.Where("status = ?", v)
|
|
}
|
|
|
|
var rows []models.RecordSet
|
|
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
|
|
return
|
|
}
|
|
out := make([]dto.RecordSetResponse, 0, len(rows))
|
|
for i := range rows {
|
|
out = append(out, recordOut(&rows[i]))
|
|
}
|
|
utils.WriteJSON(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// CreateRecordSet godoc
|
|
//
|
|
// @ID CreateRecordSet
|
|
// @Summary Create a record set (pending; Archer will UPSERT to Route 53)
|
|
// @Tags DNS
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param domain_id path string true "Domain ID (UUID)"
|
|
// @Param body body dto.CreateRecordSetRequest true "Record set payload"
|
|
// @Success 201 {object} dto.RecordSetResponse
|
|
// @Failure 400 {string} string "validation error"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "domain not found"
|
|
// @Router /dns/domains/{domain_id}/records [post]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func CreateRecordSet(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
did, err := uuid.Parse(chi.URLParam(r, "domain_id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid domain UUID")
|
|
return
|
|
}
|
|
var domain models.Domain
|
|
if err := db.Where("organization_id = ? AND id = ?", orgID, did).First(&domain).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "domain not found")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
|
|
var in dto.CreateRecordSetRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
if err := dto.DNSValidate(in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
t := strings.ToUpper(in.Type)
|
|
if t == "CNAME" && len(in.Values) != 1 {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
|
|
return
|
|
}
|
|
|
|
rel := normLowerNoDot(in.Name)
|
|
fq := fqdn(domain.DomainName, rel)
|
|
|
|
// Pre-flight: block duplicate tuple and protect from non-autoglue rows
|
|
var existing models.RecordSet
|
|
if err := db.Where("domain_id = ? AND LOWER(name) = ? AND type = ?",
|
|
domain.ID, strings.ToLower(rel), t).First(&existing).Error; err == nil {
|
|
if existing.Owner != "" && existing.Owner != "autoglue" {
|
|
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
|
|
"record with the same (name,type) exists but is not owned by autoglue")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusConflict, "already_exists",
|
|
"a record with the same (name,type) already exists; use PATCH to modify")
|
|
return
|
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
|
|
valuesJSON, _ := json.Marshal(in.Values)
|
|
fp, err := computeFingerprint(domain.ZoneID, fq, t, in.TTL, datatypes.JSON(valuesJSON))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
|
|
return
|
|
}
|
|
|
|
row := &models.RecordSet{
|
|
DomainID: domain.ID,
|
|
Name: rel,
|
|
Type: t,
|
|
TTL: in.TTL,
|
|
Values: datatypes.JSON(valuesJSON),
|
|
Fingerprint: fp,
|
|
Status: "pending",
|
|
LastError: "",
|
|
Owner: "autoglue",
|
|
}
|
|
if err := db.Create(row).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
utils.WriteJSON(w, http.StatusCreated, recordOut(row))
|
|
}
|
|
}
|
|
|
|
// UpdateRecordSet godoc
|
|
//
|
|
// @ID UpdateRecordSet
|
|
// @Summary Update a record set (flips to pending for reconciliation)
|
|
// @Tags DNS
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param id path string true "Record Set ID (UUID)"
|
|
// @Param body body dto.UpdateRecordSetRequest true "Fields to update"
|
|
// @Success 200 {object} dto.RecordSetResponse
|
|
// @Failure 400 {string} string "validation error"
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Router /dns/records/{id} [patch]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func UpdateRecordSet(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
|
return
|
|
}
|
|
|
|
var row models.RecordSet
|
|
if err := db.
|
|
Joins("Domain").
|
|
Where(`record_sets.id = ? AND "Domain"."organization_id" = ?`, id, orgID).
|
|
First(&row).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
|
|
return
|
|
}
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
var domain models.Domain
|
|
if err := db.Where("id = ?", row.DomainID).First(&domain).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
|
|
var in dto.UpdateRecordSetRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
|
|
return
|
|
}
|
|
if err := dto.DNSValidate(in); err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", err.Error())
|
|
return
|
|
}
|
|
if row.Owner != "" && row.Owner != "autoglue" {
|
|
utils.WriteError(w, http.StatusConflict, "ownership_conflict",
|
|
"record is not owned by autoglue; refuse to modify")
|
|
return
|
|
}
|
|
|
|
// Mutations
|
|
if in.Name != nil {
|
|
row.Name = normLowerNoDot(*in.Name)
|
|
}
|
|
if in.Type != nil {
|
|
row.Type = strings.ToUpper(*in.Type)
|
|
}
|
|
if in.TTL != nil {
|
|
row.TTL = in.TTL
|
|
}
|
|
if in.Values != nil {
|
|
t := row.Type
|
|
if in.Type != nil {
|
|
t = strings.ToUpper(*in.Type)
|
|
}
|
|
if t == "CNAME" && len(*in.Values) != 1 {
|
|
utils.WriteError(w, http.StatusBadRequest, "validation_error", "CNAME requires exactly one value")
|
|
return
|
|
}
|
|
b, _ := json.Marshal(*in.Values)
|
|
row.Values = datatypes.JSON(b)
|
|
}
|
|
|
|
if in.Status != nil {
|
|
row.Status = *in.Status
|
|
} else {
|
|
row.Status = "pending"
|
|
row.LastError = ""
|
|
}
|
|
|
|
fq := fqdn(domain.DomainName, row.Name)
|
|
fp, err := computeFingerprint(domain.ZoneID, fq, row.Type, row.TTL, row.Values)
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "fingerprint_error", err.Error())
|
|
return
|
|
}
|
|
row.Fingerprint = fp
|
|
|
|
if err := db.Save(&row).Error; err != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", err.Error())
|
|
return
|
|
}
|
|
utils.WriteJSON(w, http.StatusOK, recordOut(&row))
|
|
}
|
|
}
|
|
|
|
// DeleteRecordSet godoc
|
|
//
|
|
// @ID DeleteRecordSet
|
|
// @Summary Delete a record set (API removes row; worker can optionally handle external deletion policy)
|
|
// @Tags DNS
|
|
// @Produce json
|
|
// @Param X-Org-ID header string false "Organization UUID"
|
|
// @Param id path string true "Record Set ID (UUID)"
|
|
// @Success 204
|
|
// @Failure 403 {string} string "organization required"
|
|
// @Failure 404 {string} string "not found"
|
|
// @Router /dns/records/{id} [delete]
|
|
// @Security BearerAuth
|
|
// @Security OrgKeyAuth
|
|
// @Security OrgSecretAuth
|
|
func DeleteRecordSet(db *gorm.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
|
|
if !ok {
|
|
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
|
|
return
|
|
}
|
|
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
utils.WriteError(w, http.StatusBadRequest, "bad_id", "invalid UUID")
|
|
return
|
|
}
|
|
sub := db.Model(&models.RecordSet{}).
|
|
Select("record_sets.id").
|
|
Joins("JOIN domains ON domains.id = record_sets.domain_id").
|
|
Where("record_sets.id = ? AND domains.organization_id = ?", id, orgID)
|
|
|
|
res := db.Where("id IN (?)", sub).Delete(&models.RecordSet{})
|
|
if res.Error != nil {
|
|
utils.WriteError(w, http.StatusInternalServerError, "db_error", res.Error.Error())
|
|
return
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
utils.WriteError(w, http.StatusNotFound, "not_found", "record set not found")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// ---------- Out mappers ----------
|
|
|
|
func domainOut(m *models.Domain) dto.DomainResponse {
|
|
return dto.DomainResponse{
|
|
ID: m.ID.String(),
|
|
OrganizationID: m.OrganizationID.String(),
|
|
DomainName: m.DomainName,
|
|
ZoneID: m.ZoneID,
|
|
Status: m.Status,
|
|
LastError: m.LastError,
|
|
CredentialID: m.CredentialID.String(),
|
|
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
|
|
UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
func recordOut(r *models.RecordSet) dto.RecordSetResponse {
|
|
vals := r.Values
|
|
if len(vals) == 0 {
|
|
vals = datatypes.JSON("[]")
|
|
}
|
|
return dto.RecordSetResponse{
|
|
ID: r.ID.String(),
|
|
DomainID: r.DomainID.String(),
|
|
Name: r.Name,
|
|
Type: r.Type,
|
|
TTL: r.TTL,
|
|
Values: []byte(vals),
|
|
Fingerprint: r.Fingerprint,
|
|
Status: r.Status,
|
|
LastError: r.LastError,
|
|
Owner: r.Owner,
|
|
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
|
|
UpdatedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|