919 lines
31 KiB
Go
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()
|
|
}
|