Files

919 lines
31 KiB
Go

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()
}