568452846a
POST /accounts now accepts secret as a provider-specific JSON object instead of an opaque string, and validates credentials via provider.Provider.Validate before persisting — invalid credentials get a generic 400 without ever reaching Store.CreateAccount or echoing the secret back. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
346 lines
12 KiB
Go
346 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
|
)
|
|
|
|
func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
|
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid request body")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// --- accounts ---
|
|
|
|
func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
var req accountRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Provider == "" || len(req.Secret) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "provider and secret are required")
|
|
return
|
|
}
|
|
p, err := a.Reg.ByName(req.Provider)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "unknown provider")
|
|
return
|
|
}
|
|
// Trial auth up-front so bad credentials fail at creation, not at import.
|
|
// The error text is deliberately generic — never echo the secret back.
|
|
if err := p.Validate(r.Context(), provider.Credentials{Secret: string(req.Secret)}); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid provider credentials")
|
|
return
|
|
}
|
|
secretEnc, err := a.Cipher.Encrypt(req.Secret)
|
|
if err != nil {
|
|
log.Printf("api: encrypt secret failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
acc, err := a.Store.CreateAccount(r.Context(), pid, req.Provider, secretEnc, req.Comment)
|
|
if err != nil {
|
|
log.Printf("api: create account failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toAccountResponse(acc))
|
|
}
|
|
|
|
func (a *API) handleListAccounts(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
accs, err := a.Store.ListAccounts(r.Context(), pid)
|
|
if err != nil {
|
|
log.Printf("api: list accounts failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
resp := make([]accountResponse, 0, len(accs))
|
|
for _, acc := range accs {
|
|
resp = append(resp, toAccountResponse(acc))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (a *API) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
aid, err := uuid.Parse(chi.URLParam(r, "aid"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
if err := a.Store.DeleteAccount(r.Context(), aid, pid); err != nil {
|
|
log.Printf("api: delete account failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// handleImportZones lists zones from the provider for the given account and
|
|
// creates one domain per zone (template_id left unset).
|
|
func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
aid, err := uuid.Parse(chi.URLParam(r, "aid"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid account id")
|
|
return
|
|
}
|
|
acc, err := a.Store.GetAccount(r.Context(), aid, pid)
|
|
if err != nil {
|
|
log.Printf("api: import: get account failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
secret, err := a.Cipher.Decrypt(acc.SecretEnc)
|
|
if err != nil {
|
|
log.Printf("api: import: decrypt secret failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
p, err := a.Reg.ByName(acc.Provider)
|
|
if err != nil {
|
|
log.Printf("api: import: unknown provider: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
zones, err := p.ListZones(r.Context(), provider.Credentials{Secret: string(secret)})
|
|
if err != nil {
|
|
log.Printf("api: import: list zones failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
// Imported atomically: either every zone becomes a domain or none does,
|
|
// so a mid-batch provider/DB error never leaves a partial import behind.
|
|
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
|
|
if err != nil {
|
|
log.Printf("api: import: create domains failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
created := make([]domainResponse, 0, len(doms))
|
|
for _, d := range doms {
|
|
created = append(created, toDomainResponse(d))
|
|
}
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
// --- templates ---
|
|
|
|
func (a *API) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
var req templateRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeErr(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
doc := dto.TemplateDoc{Records: req.Records}
|
|
tpl, err := a.Store.CreateTemplate(r.Context(), pid, req.Name, doc)
|
|
if err != nil {
|
|
log.Printf("api: create template failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toTemplateResponse(tpl))
|
|
}
|
|
|
|
func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
tpls, err := a.Store.ListTemplates(r.Context(), pid)
|
|
if err != nil {
|
|
log.Printf("api: list templates failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
resp := make([]templateResponse, 0, len(tpls))
|
|
for _, t := range tpls {
|
|
resp = append(resp, toTemplateResponse(t))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (a *API) handleUpdateTemplate(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
tid, err := uuid.Parse(chi.URLParam(r, "tid"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid template id")
|
|
return
|
|
}
|
|
var req templateRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
writeErr(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
doc := dto.TemplateDoc{Records: req.Records}
|
|
tpl, err := a.Store.UpdateTemplate(r.Context(), tid, pid, req.Name, doc)
|
|
if err != nil {
|
|
log.Printf("api: update template failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toTemplateResponse(tpl))
|
|
}
|
|
|
|
func (a *API) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
tid, err := uuid.Parse(chi.URLParam(r, "tid"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid template id")
|
|
return
|
|
}
|
|
if err := a.Store.DeleteTemplate(r.Context(), tid, pid); err != nil {
|
|
log.Printf("api: delete template failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- domains ---
|
|
|
|
func (a *API) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
var req domainRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
accID, err := uuid.Parse(req.ProviderAccountID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid providerAccountId")
|
|
return
|
|
}
|
|
if req.ZoneName == "" || req.ZoneID == "" {
|
|
writeErr(w, http.StatusBadRequest, "zoneName and zoneId are required")
|
|
return
|
|
}
|
|
templateID, ok := parseOptionalUUID(req.TemplateID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid templateId")
|
|
return
|
|
}
|
|
|
|
// Tenant isolation: the account (and template, if given) must belong to
|
|
// this project — otherwise a caller could attach a domain to another
|
|
// tenant's provider account or DNS template.
|
|
if _, err := a.Store.GetAccount(r.Context(), accID, pid); err != nil {
|
|
writeErr(w, http.StatusNotFound, "provider account not found")
|
|
return
|
|
}
|
|
if templateID != nil {
|
|
if _, err := a.Store.GetTemplate(r.Context(), *templateID, pid); err != nil {
|
|
writeErr(w, http.StatusNotFound, "template not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
dom, err := a.Store.CreateDomain(r.Context(), pid, accID, req.ZoneName, req.ZoneID, templateID)
|
|
if err != nil {
|
|
log.Printf("api: create domain failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toDomainResponse(dom))
|
|
}
|
|
|
|
func (a *API) handleListDomains(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
doms, err := a.Store.ListDomains(r.Context(), pid)
|
|
if err != nil {
|
|
log.Printf("api: list domains failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
resp := make([]domainResponse, 0, len(doms))
|
|
for _, d := range doms {
|
|
resp = append(resp, toDomainResponse(d))
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleSetDomainTemplate binds (or clears) the DNS template used to
|
|
// check/apply a domain — this is what makes an imported domain (which
|
|
// starts with template_id=NULL) checkable, closing the import→check loop.
|
|
func (a *API) handleSetDomainTemplate(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
did, err := uuid.Parse(chi.URLParam(r, "did"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid domain id")
|
|
return
|
|
}
|
|
var req updateDomainTemplateRequest
|
|
if !decodeBody(w, r, &req) {
|
|
return
|
|
}
|
|
templateID, ok := parseOptionalUUID(req.TemplateID)
|
|
if !ok {
|
|
writeErr(w, http.StatusBadRequest, "invalid templateId")
|
|
return
|
|
}
|
|
|
|
dom, err := a.Store.SetDomainTemplate(r.Context(), did, pid, templateID)
|
|
if err != nil {
|
|
// Either the domain itself or the (scoped) template wasn't found in
|
|
// this project — treat both as 404 rather than leak which one.
|
|
writeErr(w, http.StatusNotFound, "domain or template not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toDomainResponse(dom))
|
|
}
|
|
|
|
func (a *API) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
// pid is guaranteed present and owned by the caller — RequireProjectAccess
|
|
// validated it before this handler ever runs.
|
|
pid, _ := projectIDFrom(r.Context())
|
|
did, err := uuid.Parse(chi.URLParam(r, "did"))
|
|
if err != nil {
|
|
writeErr(w, http.StatusBadRequest, "invalid domain id")
|
|
return
|
|
}
|
|
if err := a.Store.DeleteDomain(r.Context(), did, pid); err != nil {
|
|
log.Printf("api: delete domain failed: %v", err)
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|