//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
|
|
}
|