package handlers import "context" import "bytes" import "crypto/ecdsa" import "crypto/elliptic" import "crypto/rand" import "crypto/x509" import "crypto/x509/pkix" import "database/sql" import "encoding/json" import "encoding/pem" import "errors" import "fmt" import "io" import "net/http" import "net/url" import "strings" import "time" import "golang.org/x/crypto/acme" import "codit/internal/models" import "codit/internal/util" type acmeProfileRequest struct { Name string `json:"name"` DirectoryURL string `json:"directory_url"` Email string `json:"email"` Enabled bool `json:"enabled"` SolverType string `json:"solver_type"` ACMEDNSAPIURL string `json:"acme_dns_api_url"` ACMEDNSUser string `json:"acme_dns_user"` ACMEDNSKey string `json:"acme_dns_key"` ACMEDNSSubdomain string `json:"acme_dns_subdomain"` ACMEDNSFullDomain string `json:"acme_dns_full_domain"` } type acmeProfileSummary struct { ID string `json:"id"` Name string `json:"name"` DirectoryURL string `json:"directory_url"` Email string `json:"email"` AccountURL string `json:"account_url"` Enabled bool `json:"enabled"` LastError string `json:"last_error"` SolverType string `json:"solver_type"` ACMEDNSAPIURL string `json:"acme_dns_api_url"` ACMEDNSUser string `json:"acme_dns_user"` ACMEDNSKey string `json:"acme_dns_key"` ACMEDNSSubdomain string `json:"acme_dns_subdomain"` ACMEDNSFullDomain string `json:"acme_dns_full_domain"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type acmeCreateOrderRequest struct { ProfileID string `json:"profile_id"` CommonName string `json:"common_name"` SANDNS []string `json:"san_dns"` } type acmeFinalizeOrderRequest struct { RenewMode string `json:"renew_mode"` } const acmeLogID string = "acme" func (api *API) ListACMEProfiles(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.ACMEProfile var out []acmeProfileSummary var i int var err error if !api.requireAdmin(w, r) { return } items, err = api.store(r).ListACMEProfiles() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } for i = 0; i < len(items); i++ { out = append(out, buildACMEProfileSummary(items[i])) } WriteJSON(w, http.StatusOK, out) } func (api *API) CreateACMEProfile(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req acmeProfileRequest var item models.ACMEProfile var out acmeProfileSummary var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "profile create requested name=%s directory=%s", strings.TrimSpace(req.Name), strings.TrimSpace(req.DirectoryURL)) item, err = normalizeACMEProfileRequest(req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } item.Enabled = req.Enabled item.LastError = "" item, err = api.store(r).CreateACMEProfile(item) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "profile create failed name=%s err=%v", item.Name, err) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "profile create success id=%s name=%s", item.ID, item.Name) out = buildACMEProfileSummary(item) WriteJSON(w, http.StatusCreated, out) } func (api *API) UpdateACMEProfile(w http.ResponseWriter, r *http.Request, params map[string]string) { var req acmeProfileRequest var item models.ACMEProfile var normalized models.ACMEProfile var out acmeProfileSummary var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } item, err = api.store(r).GetACMEProfile(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "ACME profile not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } normalized, err = normalizeACMEProfileRequest(req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } item.Name = normalized.Name item.DirectoryURL = normalized.DirectoryURL item.Email = normalized.Email item.SolverType = normalized.SolverType item.ACMEDNSAPIURL = normalized.ACMEDNSAPIURL item.ACMEDNSUser = normalized.ACMEDNSUser item.ACMEDNSKey = normalized.ACMEDNSKey item.ACMEDNSSubdomain = normalized.ACMEDNSSubdomain item.ACMEDNSFullDomain = normalized.ACMEDNSFullDomain item.Enabled = req.Enabled item, err = api.store(r).UpdateACMEProfile(item) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "profile update failed id=%s err=%v", params["id"], err) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "profile update success id=%s name=%s enabled=%t", item.ID, item.Name, item.Enabled) out = buildACMEProfileSummary(item) WriteJSON(w, http.StatusOK, out) } func (api *API) DeleteACMEProfile(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.store(r).DeleteACMEProfile(params["id"]) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "profile delete failed id=%s err=%v", params["id"], err) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "profile delete success id=%s", params["id"]) WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func buildACMEProfileSummary(item models.ACMEProfile) acmeProfileSummary { return acmeProfileSummary{ ID: item.ID, Name: item.Name, DirectoryURL: item.DirectoryURL, Email: item.Email, AccountURL: item.AccountURL, Enabled: item.Enabled, LastError: item.LastError, SolverType: item.SolverType, ACMEDNSAPIURL: item.ACMEDNSAPIURL, ACMEDNSUser: item.ACMEDNSUser, ACMEDNSKey: item.ACMEDNSKey, ACMEDNSSubdomain: item.ACMEDNSSubdomain, ACMEDNSFullDomain: item.ACMEDNSFullDomain, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } func normalizeACMEProfileRequest(req acmeProfileRequest) (models.ACMEProfile, error) { var item models.ACMEProfile var parsed *url.URL var solver string var err error item.Name = strings.TrimSpace(req.Name) item.DirectoryURL = strings.TrimSpace(req.DirectoryURL) item.Email = strings.TrimSpace(req.Email) if item.Name == "" { return item, errors.New("name is required") } if item.DirectoryURL == "" { return item, errors.New("directory_url is required") } parsed, err = url.ParseRequestURI(item.DirectoryURL) if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { return item, errors.New("directory_url must be a valid URL") } solver = strings.TrimSpace(req.SolverType) if solver == "" { solver = "manual" } if solver != "manual" && solver != "acme_dns" { return item, errors.New("solver_type must be manual or acme_dns") } item.SolverType = solver if solver == "acme_dns" { item.ACMEDNSAPIURL = strings.TrimRight(strings.TrimSpace(req.ACMEDNSAPIURL), "/") item.ACMEDNSUser = strings.TrimSpace(req.ACMEDNSUser) item.ACMEDNSKey = strings.TrimSpace(req.ACMEDNSKey) item.ACMEDNSSubdomain = strings.TrimSpace(req.ACMEDNSSubdomain) item.ACMEDNSFullDomain = strings.TrimSpace(req.ACMEDNSFullDomain) if item.ACMEDNSAPIURL == "" { return item, errors.New("acme_dns_api_url is required for acme_dns solver") } parsed, err = url.ParseRequestURI(item.ACMEDNSAPIURL) if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") { return item, errors.New("acme_dns_api_url must be a valid URL") } if item.ACMEDNSUser == "" { return item, errors.New("acme_dns_user is required for acme_dns solver") } if item.ACMEDNSKey == "" { return item, errors.New("acme_dns_key is required for acme_dns solver") } if item.ACMEDNSSubdomain == "" { return item, errors.New("acme_dns_subdomain is required for acme_dns solver") } if item.ACMEDNSFullDomain == "" { return item, errors.New("acme_dns_full_domain is required for acme_dns solver") } } else { item.ACMEDNSAPIURL = "" item.ACMEDNSUser = "" item.ACMEDNSKey = "" item.ACMEDNSSubdomain = "" item.ACMEDNSFullDomain = "" } return item, nil } func (api *API) ListACMEOrders(w http.ResponseWriter, r *http.Request, _ map[string]string) { var profileID string var items []models.ACMEOrder var err error if !api.requireAdmin(w, r) { return } profileID = strings.TrimSpace(r.URL.Query().Get("profile_id")) items, err = api.store(r).ListACMEOrders(profileID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, items) } func (api *API) CreateACMEOrder(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req acmeCreateOrderRequest var order models.ACMEOrder var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } order, err = api.createACMEOrder(r.Context(), strings.TrimSpace(req.ProfileID), strings.TrimSpace(req.CommonName), req.SANDNS, "") if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "ACME profile not found"}) return } WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, order) } func (api *API) RenewPKICertWithACME(w http.ResponseWriter, r *http.Request, params map[string]string) { var cert models.PKICert var prev models.ACMEOrder var sans []string var parts []string var part string var i int var order models.ACMEOrder var err error if !api.requireAdmin(w, r) { return } cert, err = api.store(r).GetPKICert(strings.TrimSpace(params["id"])) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "certificate not found"}) return } prev, err = api.store(r).GetLatestACMEOrderByCertID(cert.ID) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "no previous acme order for this certificate"}) return } parts = strings.Split(cert.SANDNS, ",") for i = 0; i < len(parts); i++ { part = strings.TrimSpace(parts[i]) if part == "" { continue } sans = append(sans, part) } api.Logger.Write(acmeLogID, util.LOG_INFO, "order renew requested cert_id=%s profile=%s cn=%s", cert.ID, prev.ProfileID, cert.CommonName) order, err = api.createACMEOrder(r.Context(), prev.ProfileID, cert.CommonName, sans, cert.ID) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "ACME profile not found"}) return } WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusCreated, order) } func (api *API) createACMEOrder(ctx context.Context, profileID string, commonName string, sanDNS []string, targetCertID string) (models.ACMEOrder, error) { var profile models.ACMEProfile var order models.ACMEOrder var domains []string var domainIDs []acme.AuthzID var client *acme.Client var accountKey *ecdsa.PrivateKey var rawOrder *acme.Order var challenges []models.ACMEDNSChallenge var i int var j int var authz *acme.Authorization var ch *acme.Challenge var txt string var certKeyPEM string var csrPEM string var err error api.Logger.Write(acmeLogID, util.LOG_INFO, "order create requested profile=%s cn=%s san_count=%d", strings.TrimSpace(profileID), strings.TrimSpace(commonName), len(sanDNS)) if strings.TrimSpace(profileID) == "" { return order, errors.New("profile_id is required") } if strings.TrimSpace(commonName) == "" { return order, errors.New("common_name is required") } profile, err = api.storeContext(ctx).GetACMEProfile(strings.TrimSpace(profileID)) if err != nil { return order, err } if !profile.Enabled { return order, errors.New("ACME profile is disabled") } domains = normalizeACMEDomains(commonName, sanDNS) if len(domains) == 0 { return order, errors.New("at least one domain is required") } for i = 0; i < len(domains); i++ { domainIDs = append(domainIDs, acme.AuthzID{Type: "dns", Value: domains[i]}) } certKeyPEM, csrPEM, _, err = generateACMECSR(domains[0], domains) if err != nil { return order, err } client, accountKey, profile, err = api.ensureACMEAccount(ctx, profile) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order create failed profile=%s step=account err=%v", profile.ID, err) _ = api.storeContext(ctx).UpdateACMEProfileLastError(profile.ID, err.Error()) return order, err } _ = accountKey api.Logger.Write(acmeLogID, util.LOG_INFO, "order create account ready profile=%s account=%s", profile.ID, profile.AccountURL) rawOrder, err = client.AuthorizeOrder(context.Background(), domainIDs) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order create failed profile=%s step=authorize_order err=%v", profile.ID, err) _ = api.storeContext(ctx).UpdateACMEProfileLastError(profile.ID, err.Error()) return order, err } api.Logger.Write(acmeLogID, util.LOG_INFO, "order created upstream profile=%s order_url=%s authz_count=%d", profile.ID, rawOrder.URI, len(rawOrder.AuthzURLs)) for i = 0; i < len(rawOrder.AuthzURLs); i++ { authz, err = client.GetAuthorization(context.Background(), rawOrder.AuthzURLs[i]) if err != nil { return order, err } ch = nil for j = 0; j < len(authz.Challenges); j++ { if authz.Challenges[j] != nil && authz.Challenges[j].Type == "dns-01" { ch = authz.Challenges[j] break } } if ch == nil { return order, errors.New("dns-01 challenge not offered by ACME CA") } txt, err = client.DNS01ChallengeRecord(ch.Token) if err != nil { return order, err } challenges = append(challenges, models.ACMEDNSChallenge{ AuthorizationURL: rawOrder.AuthzURLs[i], Identifier: authz.Identifier.Value, ChallengeURL: ch.URI, Token: ch.Token, DNSName: acmeDNSRecordName(authz.Identifier.Value), DNSValue: txt, Status: ch.Status, }) api.Logger.Write(acmeLogID, util.LOG_INFO, "dns challenge prepared order=%s domain=%s dns_name=%s", rawOrder.URI, authz.Identifier.Value, acmeDNSRecordName(authz.Identifier.Value)) } order = models.ACMEOrder{ ProfileID: profile.ID, CommonName: domains[0], SANDNS: strings.Join(domains, ","), OrderURL: rawOrder.URI, FinalizeURL: rawOrder.FinalizeURL, Status: rawOrder.Status, LastError: "", CertID: strings.TrimSpace(targetCertID), Challenges: challenges, CSRPEM: csrPEM, KeyPEM: certKeyPEM, } order, err = api.storeContext(ctx).CreateACMEOrder(order) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order create persist failed profile=%s order_url=%s err=%v", profile.ID, rawOrder.URI, err) return order, err } api.Logger.Write(acmeLogID, util.LOG_INFO, "order create success id=%s profile=%s cn=%s challenge_count=%d", order.ID, order.ProfileID, order.CommonName, len(order.Challenges)) _ = api.storeContext(ctx).UpdateACMEProfileLastError(profile.ID, "") return order, nil } func (api *API) FinalizeACMEOrder(w http.ResponseWriter, r *http.Request, params map[string]string) { var order models.ACMEOrder var profile models.ACMEProfile var req acmeFinalizeOrderRequest var client *acme.Client var accountKey *ecdsa.PrivateKey var certKey *ecdsa.PrivateKey var certPEM string var certDER [][]byte var certURL string var cert models.PKICert var existing models.PKICert var certBlock *pem.Block var leaf *x509.Certificate var challenge *acme.Challenge var authz *acme.Authorization var i int var j int var replaceExisting bool var createdByKind string var createdBySubjectID string var createdBySubjectName string var issuanceSource string var err error if !api.requireAdmin(w, r) { return } req.RenewMode = "override" if r.ContentLength > 0 { err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } } if strings.TrimSpace(req.RenewMode) == "" { req.RenewMode = "override" } if req.RenewMode != "override" && req.RenewMode != "new_cert" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "renew_mode must be override or new_cert"}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "order finalize requested id=%s", params["id"]) order, err = api.store(r).GetACMEOrder(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "ACME order not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } profile, err = api.store(r).GetACMEProfile(order.ProfileID) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "ACME profile not found"}) return } if !profile.Enabled { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "ACME profile is disabled"}) return } client, accountKey, profile, err = api.ensureACMEAccount(r.Context(), profile) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order finalize failed id=%s step=account err=%v", order.ID, err) order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) _ = api.store(r).UpdateACMEProfileLastError(profile.ID, err.Error()) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } _ = accountKey for i = 0; i < len(order.Challenges); i++ { authz, err = client.GetAuthorization(context.Background(), order.Challenges[i].AuthorizationURL) if err != nil { order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if authz.Status == acme.StatusValid { order.Challenges[i].Status = acme.StatusValid api.Logger.Write(acmeLogID, util.LOG_INFO, "challenge already valid order=%s domain=%s", order.ID, order.Challenges[i].Identifier) continue } if profile.SolverType == "acme_dns" { err = api.updateACMEDNSRecord(profile, order.Challenges[i]) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "challenge dns update failed order=%s domain=%s err=%v", order.ID, order.Challenges[i].Identifier, err) order.Status = "failed" order.LastError = err.Error() order.Challenges[i].Status = "failed" _, _ = api.store(r).UpdateACMEOrder(order) _ = api.store(r).UpdateACMEProfileLastError(profile.ID, err.Error()) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "challenge dns updated order=%s domain=%s dns_name=%s", order.ID, order.Challenges[i].Identifier, order.Challenges[i].DNSName) } challenge = &acme.Challenge{Type: "dns-01", URI: order.Challenges[i].ChallengeURL, Token: order.Challenges[i].Token} challenge, err = client.Accept(context.Background(), challenge) if err != nil { order.Status = "failed" order.LastError = err.Error() order.Challenges[i].Status = "failed" _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } authz, err = client.WaitAuthorization(context.Background(), order.Challenges[i].AuthorizationURL) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "challenge wait failed order=%s domain=%s err=%v", order.ID, order.Challenges[i].Identifier, err) order.Status = "failed" order.LastError = err.Error() order.Challenges[i].Status = "failed" _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } order.Challenges[i].Status = authz.Status for j = 0; j < len(authz.Challenges); j++ { if authz.Challenges[j] != nil && authz.Challenges[j].Type == "dns-01" { order.Challenges[i].Status = authz.Challenges[j].Status break } } api.Logger.Write(acmeLogID, util.LOG_INFO, "challenge finalized order=%s domain=%s status=%s", order.ID, order.Challenges[i].Identifier, order.Challenges[i].Status) } certDER, certURL, err = client.CreateOrderCert(context.Background(), order.FinalizeURL, decodeCSRPEM(order.CSRPEM), true) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order finalize failed id=%s step=create_cert err=%v", order.ID, err) order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } certPEM = encodeCertificateChainPEM(certDER) certKey, err = parseECPrivateKeyPEM(order.KeyPEM) if err != nil { order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } _ = certKey certBlock, _ = pem.Decode([]byte(certPEM)) if certBlock == nil { order.Status = "failed" order.LastError = "invalid issued certificate" _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": order.LastError}) return } leaf, err = x509.ParseCertificate(certBlock.Bytes) if err != nil { order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } createdByKind, createdBySubjectID, createdBySubjectName, issuanceSource = pkiCertProvenanceFromRequest(r, "acme") cert = models.PKICert{ CAID: "", CreatedByKind: createdByKind, CreatedBySubjectID: createdBySubjectID, CreatedBySubjectName: createdBySubjectName, IssuanceSource: issuanceSource, SerialHex: strings.ToLower(leaf.SerialNumber.Text(16)), CommonName: order.CommonName, SANDNS: strings.Join(leaf.DNSNames, ","), SANIPs: "", IsCA: false, CertPEM: certPEM, KeyPEM: order.KeyPEM, NotBefore: leaf.NotBefore.Unix(), NotAfter: leaf.NotAfter.Unix(), Status: "active", RevokedAt: 0, RevocationReason: "", } replaceExisting = req.RenewMode == "override" if replaceExisting && strings.TrimSpace(order.CertID) != "" { existing, err = api.store(r).GetPKICert(order.CertID) if err == nil { cert.ID = existing.ID cert.CAID = existing.CAID cert, err = api.store(r).ReplacePKICert(cert) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order finalize failed id=%s step=replace_cert err=%v", order.ID, err) order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "order finalize replaced existing cert id=%s", cert.ID) } } if strings.TrimSpace(cert.ID) == "" { cert, err = api.store(r).CreatePKICert(cert) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order finalize failed id=%s step=store_cert err=%v", order.ID, err) order.Status = "failed" order.LastError = err.Error() _, _ = api.store(r).UpdateACMEOrder(order) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } } order.Status = "valid" order.LastError = "" order.CertID = cert.ID order, err = api.store(r).UpdateACMEOrder(order) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } _ = api.store(r).UpdateACMEProfileLastError(profile.ID, "") _ = certURL api.Logger.Write(acmeLogID, util.LOG_INFO, "order finalize success id=%s cert_id=%s cert_url=%s", order.ID, order.CertID, certURL) WriteJSON(w, http.StatusOK, order) } func (api *API) DeleteACMEOrder(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.store(r).DeleteACMEOrder(params["id"]) if err != nil { api.Logger.Write(acmeLogID, util.LOG_WARN, "order delete failed id=%s err=%v", params["id"], err) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } api.Logger.Write(acmeLogID, util.LOG_INFO, "order delete success id=%s", params["id"]) WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (api *API) ensureACMEAccount(ctx context.Context, profile models.ACMEProfile) (*acme.Client, *ecdsa.PrivateKey, models.ACMEProfile, error) { var key *ecdsa.PrivateKey var client *acme.Client var account *acme.Account var contact []string var keyPEM string var err error if strings.TrimSpace(profile.AccountKeyPEM) == "" { key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, nil, profile, err } } else { key, err = parseECPrivateKeyPEM(profile.AccountKeyPEM) if err != nil { return nil, nil, profile, err } } client = &acme.Client{Key: key, DirectoryURL: strings.TrimSpace(profile.DirectoryURL), UserAgent: "codit-acme"} if strings.TrimSpace(profile.AccountURL) != "" { client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL)) return client, key, profile, nil } if strings.TrimSpace(profile.Email) != "" { contact = append(contact, "mailto:"+strings.TrimSpace(profile.Email)) } account, err = client.Register(context.Background(), &acme.Account{Contact: contact}, acme.AcceptTOS) if err != nil { var acmeErr *acme.Error if errors.As(err, &acmeErr) && acmeErr.StatusCode == http.StatusConflict { account, err = client.GetReg(context.Background(), "") if err != nil { return nil, nil, profile, err } } else { return nil, nil, profile, err } } if account != nil { profile.AccountURL = account.URI } keyPEM, err = encodeECPrivateKeyPEM(key) if err != nil { return nil, nil, profile, err } profile.AccountKeyPEM = keyPEM err = api.storeContext(ctx).UpdateACMEProfileAccount(profile.ID, profile.AccountURL, profile.AccountKeyPEM) if err != nil { return nil, nil, profile, err } client.KID = acme.KeyID(strings.TrimSpace(profile.AccountURL)) return client, key, profile, nil } type acmeDNSUpdateRequest struct { Subdomain string `json:"subdomain"` TXT string `json:"txt"` } func (api *API) updateACMEDNSRecord(profile models.ACMEProfile, challenge models.ACMEDNSChallenge) error { var reqBody acmeDNSUpdateRequest var payload []byte var endpoint string var request *http.Request var response *http.Response var client http.Client var rawBody []byte var err error reqBody.Subdomain = strings.TrimSpace(profile.ACMEDNSSubdomain) reqBody.TXT = strings.TrimSpace(challenge.DNSValue) if reqBody.Subdomain == "" { return errors.New("acme_dns_subdomain is not configured") } if reqBody.TXT == "" { return errors.New("dns challenge value is empty") } payload, err = json.Marshal(reqBody) if err != nil { return err } endpoint = strings.TrimRight(strings.TrimSpace(profile.ACMEDNSAPIURL), "/") if endpoint == "" { return errors.New("acme_dns_api_url is not configured") } request, err = http.NewRequest(http.MethodPost, endpoint+"/update", bytes.NewReader(payload)) if err != nil { return err } request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Api-User", strings.TrimSpace(profile.ACMEDNSUser)) request.Header.Set("X-Api-Key", strings.TrimSpace(profile.ACMEDNSKey)) client.Timeout = 20 * time.Second response, err = client.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { rawBody, _ = io.ReadAll(io.LimitReader(response.Body, 4096)) return fmt.Errorf("acme-dns update failed status=%d body=%s", response.StatusCode, strings.TrimSpace(string(rawBody))) } return nil } func normalizeACMEDomains(commonName string, san []string) []string { var set map[string]bool var out []string var i int var value string set = map[string]bool{} value = strings.ToLower(strings.TrimSpace(commonName)) if value != "" { set[value] = true out = append(out, value) } for i = 0; i < len(san); i++ { value = strings.ToLower(strings.TrimSpace(san[i])) if value == "" { continue } if set[value] { continue } set[value] = true out = append(out, value) } return out } func acmeDNSRecordName(identifier string) string { var host string host = strings.TrimSpace(identifier) host = strings.TrimPrefix(host, "*.") if host == "" { return "_acme-challenge" } return "_acme-challenge." + host } func generateACMECSR(commonName string, domains []string) (string, string, []byte, error) { var key *ecdsa.PrivateKey var csrTemplate x509.CertificateRequest var csrDER []byte var keyDER []byte var keyPEM string var csrPEM string var err error key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return "", "", nil, err } csrTemplate = x509.CertificateRequest{Subject: pkix.Name{CommonName: commonName}, DNSNames: domains} csrDER, err = x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key) if err != nil { return "", "", nil, err } keyDER, err = x509.MarshalECPrivateKey(key) if err != nil { return "", "", nil, err } keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) csrPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})) return keyPEM, csrPEM, csrDER, nil } func parseECPrivateKeyPEM(raw string) (*ecdsa.PrivateKey, error) { var block *pem.Block var key *ecdsa.PrivateKey var parsed any var err error block, _ = pem.Decode([]byte(raw)) if block == nil { return nil, errors.New("invalid private key pem") } if block.Type == "EC PRIVATE KEY" { key, err = x509.ParseECPrivateKey(block.Bytes) if err != nil { return nil, err } return key, nil } parsed, err = x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } key, _ = parsed.(*ecdsa.PrivateKey) if key == nil { return nil, errors.New("private key is not ecdsa") } return key, nil } func encodeECPrivateKeyPEM(key *ecdsa.PrivateKey) (string, error) { var der []byte var err error der, err = x509.MarshalECPrivateKey(key) if err != nil { return "", err } return string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})), nil } func decodeCSRPEM(raw string) []byte { var block *pem.Block block, _ = pem.Decode([]byte(raw)) if block == nil { return nil } return block.Bytes } func encodeCertificateChainPEM(chain [][]byte) string { var builder strings.Builder var i int for i = 0; i < len(chain); i++ { builder.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: chain[i]})) } return builder.String() }