Lets Encrypt certificate renewal API for server cluster and getssl.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

557 lines
16 KiB

//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
)
type ApiDomainInput struct {
Data DomainInput `json:"data"`
}
type ApiDomainInputMulti struct {
Data []DomainInput `json:"data"`
}
type DomainInput struct {
Domain string `json:"domain"`
ChallengeText string `json:"challenge,omitempty"`
CheckDomain string `json:"check_domain,omitempty"`
}
type UpOut struct {
Up bool `json:"up,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
Uptime string `json:"uptime,omitempty"`
}
type APIOutput struct {
Status int `json:"status,omitempty"`
Message string `json:"message,omitempty"`
Data []interface{} `json:"data"`
}
/////////////////////////////////////////////
///// API ROUTE FUNCTIONS
/////////////////////////////////////////////
func apiListCertGroups(c echo.Context) error {
var out APIOutput
out.Status = http.StatusOK
out.Message = "certificate groups"
out.Data = make([]interface{}, 0) //make empty slice so if there are no results JSON will contain [] instead of null
for _, cg := range certgroups {
out.Data = append(out.Data, cg)
}
return c.JSON(out.Status, out)
}
func apiRenew(c echo.Context) error {
for n, _ := range certgroups {
err := renew(n)
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
}
}
return c.JSON(okOut())
}
func apiReload(c echo.Context) error {
err := reload()
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Error reloading services: "+err.Error()))
}
return c.JSON(okOut())
}
func apiReloadAll(c echo.Context) error {
err := reload()
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Error reloading services: "+err.Error()))
}
return c.JSON(okOut())
}
func reloadAllServers() error {
var theError error
numservers := len(servers)
c := make(chan string)
var wg sync.WaitGroup
wg.Add(numservers)
for n := 0; n < numservers; n++ {
go func(c chan string) {
for {
srv, more := <-c
if more == false {
wg.Done()
return
}
log.Println("Concurrent execution send reload to server: " + srv + "...")
err := reloadRemote(srv)
if err != nil {
log.Println(err.Error())
theError = err
}
}
}(c)
}
for _, server := range servers { //send each server to the channel
if server == appconf.Hostname { //don't send myself
continue
}
c <- server
}
close(c)
wg.Wait()
if theError == nil {
log.Println("Finished requsting reload to all servers.")
}
return theError //if any one or more fail, return an error for it (the last one that fails)
}
func apiAddDns(c echo.Context) error { //used by getssl to add _acme-challenge DNS record
input := new(ApiDomainInput)
if err := c.Bind(input); err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data."))
}
if len(input.Data.Domain) == 0 || len(input.Data.ChallengeText) == 0 {
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data."))
}
err := kazooDnsPublish(input.Data.Domain, input.Data.ChallengeText, "request")
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error publishing DNS ADD message."+err.Error()))
}
return c.JSON(okOut())
}
func apiDelDns(c echo.Context) error {
input := new(ApiDomainInput)
if err := c.Bind(input); err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data."))
}
if len(input.Data.Domain) == 0 || len(input.Data.ChallengeText) == 0 {
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data."))
}
err := kazooDnsPublish(input.Data.Domain, input.Data.ChallengeText, "delete")
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error publishing DNS DELETE message."+err.Error()))
}
return c.JSON(okOut())
}
//Receive file and store it
func apiUpload(c echo.Context) error {
fileType := c.Param("fileType")
cert_idx_str := c.Param("cert_idx")
r := c.Request()
var filePath string
switch fileType {
case "ca":
filePath = appconf.TLSCAPath + cert_idx_str + ".crt"
case "chain":
filePath = appconf.TLSChainPath + cert_idx_str + ".crt"
case "key":
filePath = appconf.TLSKeyPath + cert_idx_str + ".key"
case "cert":
filePath = appconf.TLSCertPath + cert_idx_str + ".crt"
case "pem":
filePath = appconf.TLSPEMPath + cert_idx_str + ".pem"
default: //ACL
//return c.JSON(errorOut(http.StatusBadRequest, "Invalid filetype/URL."))
filePath = appconf.LetsEncryptValidationPath + "/" + fileType
}
directory, _ := path.Split(filePath)
//Check and create directory
if _, err := os.Stat(directory); os.IsNotExist(err) {
err = os.MkdirAll(directory, 0755)
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Filetype "+fileType+" directory does not exist, and could not create: "+err.Error()))
}
}
//Read the upload data
var blimit int64 = 102400 //100k max upload size
body, err := ioutil.ReadAll(io.LimitReader(r.Body, blimit))
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error reading post body: "+err.Error()))
}
//Write the file
err = ioutil.WriteFile(filePath, body, 0644)
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Could not write file: "+err.Error()))
}
log.Println("Received PUT to: " + r.RequestURI)
log.Println("Writing to: " + filePath)
return c.JSON(okOut())
}
func apiUploadSync(c echo.Context) error {
fileType := c.Param("fileType")
cert_idx := c.Param("cert_idx")
r := c.Request()
//Read the upload data
var blimit int64 = 102400 //100k max upload size
body, err := ioutil.ReadAll(io.LimitReader(r.Body, blimit))
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error reading PUT body: "+err.Error()))
}
uuid := generateUUID()
filePath := appconf.SrvDir + "/" + fileType + "__" + uuid + ".tmpfile"
log.Println("Received PUT for sync to " + r.RequestURI)
log.Println("Writing to " + filePath)
//Write the file
err = ioutil.WriteFile(filePath, body, 0644)
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Could not write temporary file: "+err.Error()))
}
err = sendFileToAllServers(filePath, cert_idx)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error sending file "+filePath+" to other servers: "+err.Error()))
}
return c.JSON(okOut())
}
func apiListDomains(c echo.Context) error {
var out APIOutput
out.Status = http.StatusOK
out.Message = "domains list"
out.Data = make([]interface{}, 0) //make empty slice so if there are no results JSON will contain [] instead of null
for _, cg := range certgroups {
out.Data = append(out.Data, cg)
}
return c.JSON(out.Status, out)
}
func apiPutDomain(c echo.Context) error {
if c.Request().Method == http.MethodOptions {
return c.NoContent(http.StatusOK)
}
domain := c.Param("domain")
domainToCheck := domain
if len(domain) == 0 {
input := new(ApiDomainInput)
if err := c.Bind(input); err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data."))
}
if len(input.Data.Domain) == 0 {
return c.JSON(errorOut(http.StatusBadRequest, "Bad input data: empty domain."))
}
domain = input.Data.Domain
if len(input.Data.CheckDomain) > 0 {
domainToCheck = input.Data.CheckDomain
}
}
//check for dups
for _, cg := range certgroups {
if cg.PrimaryDomain == domain {
return c.JSON(errorOut(http.StatusBadRequest, "Bad request: Domain already exists."))
}
for _, d := range cg.Domains {
if d == domain {
return c.JSON(errorOut(http.StatusBadRequest, "Bad request: Domain already exists."))
}
}
}
wildcard := strings.HasPrefix(domain, "*.") //is wildcard domain
var certgroup_slot int
//add domain to list
for n, cg := range certgroups { //nothing will happen in this loop if certgroups is empty
//can't have more than 100 names on a single cert
//wildcard domains can only be added to wildcard certgroup
//non-wildcard domains can only be added to non-wildcard certgroup
if len(cg.Domains) < (appconf.MaxDomainsPerCert-1) && ((wildcard && cg.Wildcard) || (!wildcard && !cg.Wildcard)) {
cg.Domains = append(cg.Domains, domain)
certgroups[n] = cg //replace certgroup with modified/appended version
certgroup_slot = n //set slot we need to run renewal for
break
} else if len(certgroups) == (n + 1) { //all certgroup slots are full (or incompatible), and we are on the last one, so make another
var newcg CertGroup
if wildcard {
newcg.Wildcard = true
//use api.domain.com instead of *.domain.com for primary domain:
//newcg.PrimaryDomain = "api" + domain[1:] //FIXME ??? getssl does support wildcards as primary domain, but there seems to be issues calling it with exec.Command when a wildcard is involved
//newcg.Domains = append(cg.Domains, domain)
newcg.PrimaryDomain = domain
newcg.Domains = make([]string, 0) //make empty slice so if there are no results JSON will contain [] instead of null
} else {
newcg.PrimaryDomain = domain
newcg.Domains = make([]string, 0)
}
certgroups = append(certgroups, newcg)
certgroup_slot = n + 1 //set slot we need to run renewal for
break
}
}
if len(certgroups) == 0 { //certgroups is empty, so start fresh
var cg CertGroup
cg.PrimaryDomain = appconf.PrimaryDomain
cg.Wildcard = wildcard
if domain != appconf.PrimaryDomain {
cg.Domains = append(cg.Domains, domain)
} else {
cg.Domains = make([]string, 0)
}
certgroups = append(certgroups, cg)
}
//write list to disk
err := writeDomains()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error writing domains list to disk: "+err.Error()))
}
//sync with other servers
err = syncAllServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error()))
}
//renew cert
err = renew(certgroup_slot)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusUnprocessableEntity, "Error renewing: "+err.Error()))
}
if strings.HasPrefix(domainToCheck, "*.") { //domainToCheck is wildcard (check domain wasn't specified)
domainToCheck = "api." + domainToCheck[2:] //fallback to checking api.domain.com
}
certPath := appconf.TLSCertPath + fmt.Sprintf("%02d", certgroup_slot) + ".crt"
err = checkDomain(domainToCheck, certPath)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusFailedDependency, "Renewal completed, but check failed: "+err.Error()))
}
return c.JSON(okOut())
}
func apiDeleteDomain(c echo.Context) error {
deleteDomain := c.Param("domain")
var certgroup_slot int
for n, cg := range certgroups {
var newlist []string
if cg.PrimaryDomain == deleteDomain && cg.PrimaryDomain != appconf.PrimaryDomain {
if len(cg.Domains) > 0 {
cg.PrimaryDomain = cg.Domains[0]
deleteDomain = cg.Domains[0] //domain list will be rebuilt in the for loop below without this one
} else {
//TODO: handle deletion of primary domain in a certgroup, even with no domain list
continue //don't delete primary domain since there isn't any to replace it (have to manually modify domains.json)
}
}
for _, d := range cg.Domains {
if d == deleteDomain {
certgroup_slot = n //save the slot this deleted domain is in
} else { //rebuild the list with domains not deleted
newlist = append(newlist, d)
}
}
cg.Domains = newlist
certgroups[n] = cg //replace this slot
}
//write list to disk
err := writeDomains()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error writing domains list to disk: "+err.Error()))
}
//sync with other servers
err = syncAllServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error()))
}
//renew cert
err = renew(certgroup_slot)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
}
return c.JSON(okOut())
}
func apiListServers(c echo.Context) error {
var out APIOutput
out.Status = http.StatusOK
out.Message = "servers list"
out.Data = make([]interface{}, 0) //make empty slice so if there are no results JSON will contain [] instead of null
for _, server := range servers {
out.Data = append(out.Data, server)
}
return c.JSON(out.Status, out)
}
func apiPutServer(c echo.Context) error {
server := c.Param("server")
//check for dups
for _, s := range servers {
if s == server {
return c.JSON(errorOut(http.StatusBadRequest, "Bad request: Server already exists."))
}
}
//add servers to list
servers = append(servers, server)
//write list to disk
err := writeServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error writing servers list to disk: "+err.Error()))
}
//sync with other servers
err = syncAllServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error()))
}
//renew cert
for n, _ := range certgroups {
err = renew(n)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
}
}
return c.JSON(okOut())
}
func apiDeleteServer(c echo.Context) error {
deleteServer := c.Param("server")
var newlist []string
for _, s := range servers {
if s != deleteServer {
newlist = append(newlist, s)
}
}
servers = newlist
//write list to disk
err := writeServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error writing servers list to disk: "+err.Error()))
}
//sync with other servers
err = syncAllServers()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error()))
}
//renew cert
for n, _ := range certgroups {
err = renew(n)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
}
}
return c.JSON(okOut())
}
func apiSync(c echo.Context) error {
host := c.Param("host")
log.Println("Received sync request for host: " + host + ". From IP address: " + c.RealIP() + " Syncing...")
err := syncServersFromHost(host)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing servers from host: "+host+". "+err.Error()))
}
err = syncDomainsFromHost(host)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing domains from host: "+host+". "+err.Error()))
}
return c.JSON(okOut())
}
func nothingResponse(c echo.Context) error {
return c.NoContent(http.StatusNotFound)
}
func uptimeCheck(c echo.Context) error {
if c.Request().Method == http.MethodHead {
return c.NoContent(http.StatusOK)
}
//return c.String(http.StatusOK, "{\"up\":true}")
return c.JSON(http.StatusOK, uptime())
}
func okOut() (int, APIOutput) {
var out APIOutput
out.Message = "success"
out.Status = http.StatusOK
return out.Status, out
}
func errorOut(status int, msg string) (int, APIOutput) {
var out APIOutput
out.Message = msg
out.Status = status
return out.Status, out
}