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