//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved package main import ( "bytes" "crypto/tls" "encoding/json" "errors" "fmt" "io/ioutil" "log" "net/http" "os" "os/exec" "path" "strconv" "strings" "sync" ) func writeDomains() error { b := new(bytes.Buffer) err := json.NewEncoder(b).Encode(certgroups) if err != nil { return errors.New("Couldn't encode certgroups struct into JSON: " + err.Error()) } err = ioutil.WriteFile(configDir+"/domains.json", b.Bytes(), 0644) if err != nil { return errors.New("Couldn't write domains file: " + configDir + "/domains.json") } return nil } func writeServers() error { b := new(bytes.Buffer) err := json.NewEncoder(b).Encode(servers) if err != nil { return errors.New("Couldn't encode servers list into JSON: " + err.Error()) } err = ioutil.WriteFile(configDir+"/servers.json", b.Bytes(), 0644) if err != nil { return errors.New("Couldn't write servers file: " + configDir + "/servers.json") } return nil } func sendFileToAllServers(filePath, cert_idx_str string) 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("Parallel execution send file to server: " + srv + "...") err := sendFileToServer(filePath, srv, cert_idx_str) 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 sending file " + filePath + " to all servers.") } err := os.Remove(filePath) if err != nil { log.Println("Error deleting temporary file: " + filePath + " - " + err.Error()) } return theError //if any one or more fail, return an error for it (the last one that fails) } func sendFileToServer(filePath, server, cert_idx_str string) error { log.Println("Send file " + filePath + " to " + server + " starting...") _, fileName := path.Split(filePath) fileTypeOrACL := strings.SplitN(fileName, "__", 2)[0] //cert__abcdef1234567890.tmpfile --> "cert" data, err := os.Open(filePath) if err != nil { return errors.New("sendFileToServer: Could not open temporary file " + filePath + ": " + err.Error()) } //url := syncScheme + server + ":" + syncPort + "/api/file/upload/" + fileType + "/" + fmt.Sprintf("%02d", cert_idx) url := syncScheme + server + ":" + syncPort + "/api/file/upload/" + fileTypeOrACL + "/" + cert_idx_str if len(cert_idx_str) == 0 { // file is missing cert index number string, so is an ACL file url = syncScheme + server + ":" + syncPort + "/api/file/upload/" + fileTypeOrACL } log.Println("Send file '" + filePath + "' to " + url + "...") req, err := http.NewRequest("PUT", url, data) if err != nil { log.Println(err.Error()) return errors.New("Couldn't create new HTTP file upload request for server: " + server) } req.Close = true req.Header.Set("User-Agent", myUserAgent) req.SetBasicAuth(appconf.Username, appconf.SecretKey) //req.Header.Set("Authorization", "Bearer "+appconf.SecretKey) //skip verification of cert, since the cert may not be setup properly at first customTransport := http.DefaultTransport.(*http.Transport).Clone() customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} client := &http.Client{Transport: customTransport, Timeout: timeout} //client := &http.Client{Timeout: timeout} response, err := client.Do(req) if err != nil { log.Println(err.Error()) return errors.New("Couldn't do HTTP file upload to server: " + server) } body, err := ioutil.ReadAll(response.Body) if err != nil { log.Println(err.Error()) return errors.New("Couldn't parse response body on request to server: " + server) } if response.StatusCode != 200 { errorString := "Problem uploading file to server " + server + ". Status code: " + strconv.Itoa(response.StatusCode) + " Body: " + string(body) log.Println(errorString) return errors.New(errorString) } log.Println("Upload [" + fileTypeOrACL + "] to " + server + " success!") return nil } func renew(cert_idx int) error { log.Println("Renew operation initiated for certgroup [" + strconv.Itoa(cert_idx) + "]...") //BUILD/SET GETSSL ENVIRONMENT VARIABLES THEN EXECUTE GETSSL //domain list var domainlist string cg := certgroups[cert_idx] domains := cg.Domains for _, d := range domains { if d == cg.PrimaryDomain { //ignore primary domain continue } domainlist = domainlist + "," + d } domainlist = strings.TrimLeft(domainlist, ",") //Take off leading comma err := os.Setenv("SANS", domainlist) if err != nil { return errors.New("RENEW: error setting SANS domains list environment variable: " + err.Error()) } if appconf.Debug { log.Println(domainlist) } // GetSSL Expected WebDAV Format: (HTTPS ONLY!) // ";davs:davsuser:davspassword:{DOMAIN}:443:/path" //ACL string aclstring := appconf.LetsEncryptValidationPath if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } aclstring += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.LetsEncryptValidationPath } } else { //file sync type is HTTPS aclstring += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync" //aclstring += " ;cmd:\"curl -s -k -u" + appconf.Username + "\\:" + appconf.SecretKey + " " + syncScheme + appconf.Hostname + ":" + syncPort + "/api/file/upload/" + "$destfile" + " -T $src \"" + ":" + appconf.LetsEncryptValidationPath } err = os.Setenv("ACL", aclstring) if err != nil { return errors.New("RENEW: error setting ACL environment variable: " + err.Error()) } if appconf.Debug { log.Println("ACL STRING:") log.Println(aclstring) } //Cert and key locations domain_cert_location := appconf.TLSCertPath + fmt.Sprintf("%02d", cert_idx) + ".crt" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } domain_cert_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSCertPath + fmt.Sprintf("%02d", cert_idx) + ".crt" } } else { //file sync type is HTTPS // ;davs:user:pass:hostname:port:/api/file/sync/cert/{cert_idx} domain_cert_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/cert/" + fmt.Sprintf("%02d", cert_idx) } err = os.Setenv("DOMAIN_CERT_LOCATION", domain_cert_location) if err != nil { return errors.New("RENEW: error setting DOMAIN_CERT_LOCATION environment variable: " + err.Error()) } domain_key_location := appconf.TLSKeyPath + fmt.Sprintf("%02d", cert_idx) + ".key" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } domain_key_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSKeyPath + fmt.Sprintf("%02d", cert_idx) + ".key" } } else { //file sync type is HTTPS domain_key_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/key/" + fmt.Sprintf("%02d", cert_idx) } err = os.Setenv("DOMAIN_KEY_LOCATION", domain_key_location) if err != nil { return errors.New("RENEW: error setting DOMAIN_KEY_LOCATION environment variable: " + err.Error()) } domain_chain_location := appconf.TLSChainPath + fmt.Sprintf("%02d", cert_idx) + ".crt" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } domain_chain_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSChainPath + fmt.Sprintf("%02d", cert_idx) + ".crt" } } else { //file sync type is HTTPS domain_chain_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/chain/" + fmt.Sprintf("%02d", cert_idx) } err = os.Setenv("DOMAIN_CHAIN_LOCATION", domain_chain_location) if err != nil { return errors.New("RENEW: error setting DOMAIN_CHAIN_LOCATION environment variable: " + err.Error()) } domain_fullpem_location := appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } domain_fullpem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" } } else { //file sync type is HTTPS domain_fullpem_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/pem/" + fmt.Sprintf("%02d", cert_idx) } err = os.Setenv("DOMAIN_PEM_LOCATION", domain_fullpem_location) if err != nil { return errors.New("RENEW: error setting DOMAIN_PEM_LOCATION environment variable: " + err.Error()) } //these parameters don't seem to be respected by GetSSL from environment variables, so write them to config file: ca_cert_location := appconf.TLSCAPath + fmt.Sprintf("%02d", cert_idx) + ".crt" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } ca_cert_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSCAPath + fmt.Sprintf("%02d", cert_idx) + ".crt" } } else { //file sync type is HTTPS ca_cert_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/ca/" + fmt.Sprintf("%02d", cert_idx) } reload_command := appconf.ReloadCommand for _, server := range servers { if server == appconf.Hostname { continue } //old ssh method; requires ssh key //reload_command += "; ssh " + appconf.Username + "@" + server + " '" + appconf.ReloadCommand + "'" //new method; calls LEAPI to trigger reload reload_command += " ; curl -s -k -X POST -H 'Authorization: Bearer " + appconf.SecretKey + "' " + syncScheme + server + ":" + appconf.HTTPS_ServerPort + "/api/reload" //reload_command += "; 'curl -s -X POST -H \\\"Authorization: Bearer " + appconf.SecretKey + "\\\" " + syncScheme + server + "/api/reload" } ca_server := "https://acme-staging-v02.api.letsencrypt.org" if appconf.Production { ca_server = "https://acme-v02.api.letsencrypt.org" } var configFile string configFile = "CA=\"" + ca_server + "\"\n" configFile += "USE_SINGLE_ACL=\"true\"\n" configFile += "CA_CERT_LOCATION=\"" + ca_cert_location + "\"\n" configFile += "RELOAD_CMD=\"" + reload_command + "\"\n" configFile += "RENEW_ALLOW=\"" + appconf.RenewAllow + "\"\n" configFile += "CHECK_REMOTE=\"false\"\n" //configFile += "CHECK_REMOTE=\"true\"\n" //configFile += "SERVER_TYPE=\"" + appconf.CheckPort + "\"\n" //configFile += "CHECK_REMOTE_WAIT=\"" + strconv.Itoa(appconf.CheckWaitSec) + "\"\n" //if this certgroup is a wildcard cert group, set the validation mode to DNS and write the DNS add scripts that call myself. if cg.Wildcard { configFile += "VALIDATE_VIA_DNS=true\n" configFile += "LEAPI_URL=" + syncScheme + appconf.Hostname + ":" + syncPort + "\n" configFile += "LEAPI_APITOKEN=" + appconf.SecretKey + "\n" configFile += "DNS_ADD_COMMAND=" + configDir + "/dns_add_kazoo\n" configFile += "DNS_DEL_COMMAND=" + configDir + "/dns_del_kazoo\n" //write DNS add/del scripts err = writeDnsScriptFile("add") if err != nil { return errors.New("RENEW: " + err.Error()) } err = writeDnsScriptFile("del") if err != nil { return errors.New("RENEW: " + err.Error()) } } dir := configDir + "/" + cg.PrimaryDomain //Check and create config file directory if _, err := os.Stat(dir); os.IsNotExist(err) { //err = os.MkdirAll(configDir+"/"+cg.PrimaryDomain, os.ModeDir) err = os.MkdirAll(dir, 0755) if err != nil { return errors.New("Couldn't create directory '" + dir + "': " + err.Error()) } } //write config file err = ioutil.WriteFile(configDir+"/"+cg.PrimaryDomain+"/getssl.cfg", []byte(configFile), 0644) if err != nil { return errors.New("Couldn't write getssl config file: " + configDir + "/" + cg.PrimaryDomain + "/getssl.cfg") } if appconf.Debug { //////PRINT VARS for _, e := range os.Environ() { log.Println(e) } } //RUN GETSSL //first patch getssl to disable cert verification checking: if appconf.Debug { log.Println("First patch getssl to disable cert verification checking...") } cmd := exec.Command("/usr/bin/sed", "-i", "s/NOMETER} -u/NOMETER} -k -u/g", appconf.SrvDir+"/getssl") output, err := cmd.CombinedOutput() if err != nil { log.Println(string(output)) return errors.New("RENEW: patching of getssl to disable curl certificate verification during LEAPI sync failed: " + err.Error()) } //RUN getssl on primary domain to renew return executeGetssl(cg.PrimaryDomain) } func executeGetssl(domain string) error { /* getssl := appconf.SrvDir + "/getssl -w " + appconf.SrvDir + " \"" + domain + "\"" if appconf.Debug { getssl = appconf.SrvDir + "/getssl -d -w " + appconf.SrvDir + " \"" + domain + "\"" } log.Println("Executing getssl with: '" + getssl + "'...") execScript := "#!/bin/sh\n" execScript += getssl + "\n" //write script file to run reload command[s] err := ioutil.WriteFile(configDir+"/execgetssl.sh", []byte(execScript), 0755) if err != nil { return errors.New("Couldn't write execgetssl.sh script file: " + configDir + "/execgetssl.sh") } cmd := exec.Command(appconf.SrvDir + "/execgetssl.sh") */ log.Println("Executing getssl on primary domain: " + domain + "...") cmd := exec.Command(appconf.SrvDir+"/getssl", "-w", appconf.SrvDir, domain) if appconf.Debug { cmd = exec.Command(appconf.SrvDir+"/getssl", "-d", "-w", appconf.SrvDir, domain) } output, err := cmd.CombinedOutput() log.Println("====================== BEGIN GETSSL OUTPUT: ======================") log.Println(string(output)) log.Println("======================= END GETSSL OUTPUT ========================") if err != nil { return errors.New("RENEW: execution of getssl failed: " + err.Error() + " Check log file " + appconf.LogFile + " for more details.") } return nil } func reload() error { //To avoid problems with spaces in the command, we build a script file and run it in shell, rather than directly. reloadScript := "#!/bin/sh\n" reloadScript += appconf.ReloadCommand + "\n" //write script file to run reload command[s] err := ioutil.WriteFile(configDir+"/reloadscript.sh", []byte(reloadScript), 0755) if err != nil { return errors.New("Couldn't write reload script file: " + configDir + "/reloadscript.sh") } cmd := exec.Command(appconf.SrvDir + "/reloadscript.sh") output, err := cmd.CombinedOutput() if err != nil { log.Println("BEGIN RELOADSCRIPT OUTPUT:") log.Println(string(output)) log.Println("END RELOADSCRIPT OUTPUT") return errors.New("RELOAD: execution of reload script failed: " + err.Error()) } log.Println("BEGIN RELOADSCRIPT OUTPUT:") log.Println(string(output)) log.Println("END RELOADSCRIPT OUTPUT") return nil } func reloadRemote(server string) error { url := syncScheme + server + ":" + syncPort + "/api/reload" req, err := http.NewRequest("POST", url, nil) if err != nil { log.Println(err.Error()) return errors.New("Couldn't create new HTTP reload request for server: " + server) } req.Close = true req.Header.Set("User-Agent", myUserAgent) req.SetBasicAuth(appconf.Username, appconf.SecretKey) //req.Header.Set("Authorization", "Bearer "+appconf.SecretKey) //skip verification of cert, since the cert may not be setup properly at first customTransport := http.DefaultTransport.(*http.Transport).Clone() customTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} client := &http.Client{Transport: customTransport, Timeout: timeout} //client := &http.Client{Timeout: timeout} response, err := client.Do(req) if err != nil { log.Println(err.Error()) return errors.New("Couldn't send HTTP remote reload to server: " + server) } body, err := ioutil.ReadAll(response.Body) if err != nil { log.Println(err.Error()) return errors.New("Couldn't parse response body on request to server: " + server) } if response.StatusCode != 200 { errorString := "Problem sending remote reload to server " + server + ". Status code: " + strconv.Itoa(response.StatusCode) + " Body: " + string(body) log.Println(errorString) return errors.New(errorString) } return nil } func checkDomain(domain, certPath string) error { log.Println("Checking for successful installation of certificate on '" + domain + "' (verify fingerprint)...") //could do this in go with tls.Dial but using openssl is quicker and easier opensslLocalCertCmd := "openssl x509 -noout -fingerprint < " + certPath + " 2>/dev/null" localCertFP, err := exec.Command("/bin/sh", "-c", opensslLocalCertCmd).CombinedOutput() if err != nil { log.Println("Error executing '" + opensslLocalCertCmd + "'. ERROR: " + err.Error() + " OUTPUT: " + string(localCertFP)) return errors.New("Local cert fingerprint check failed: " + err.Error()) } opensslRemoteCertCmd := "openssl s_client -servername " + domain + " -connect " + domain + ":" + appconf.CheckPort + " 2>/dev/null | openssl x509 -noout -fingerprint 2>/dev/null" remoteCertFP, err := exec.Command("/bin/sh", "-c", opensslRemoteCertCmd).CombinedOutput() if err != nil { log.Println("Error executing '" + opensslRemoteCertCmd + "'. ERROR: " + err.Error() + " OUTPUT: " + string(remoteCertFP)) return errors.New("Remote cert fingerprint check failed: " + err.Error()) } if appconf.Debug { log.Printf("Local certificate fingerprint: " + string(localCertFP)) log.Printf("Remote certificate fingerprint: " + string(remoteCertFP)) } if string(remoteCertFP) != string(localCertFP) { return errors.New("Remote certificate check failed: certificate fingerprints do not match! Local: " + string(localCertFP) + "Remote: " + string(remoteCertFP)) } return nil } func writeDnsScriptFile(action string) (err error) { script := `#!/usr/bin/env bash url=${LEAPI_URL:-'` + syncScheme + appconf.Hostname + ":" + syncPort + `'} #base URL apitoken=${LEAPI_APITOKEN:-'` + appconf.SecretKey + `'} fulldomain="${1}" challenge="${2}" echo "url = $url" echo "apitoken = $apitoken" echo "fulldomain = $fulldomain" echo "challenge = $challenge" # Check initial parameters if [[ -z "$fulldomain" ]]; then echo "DNS script requires full domain name as first parameter" exit 1 fi if [[ -z "$challenge" ]]; then echo "DNS script requires challenge token as second parameter" exit 1 fi if [[ -z "$apitoken" ]] && [[ -z "$password" ]]; then echo "Must set LEAPI_APITOKEN in dns script, environment variable or getssl.cfg" exit 1 fi if [[ -z "$url" ]]; then echo "LEAPI_URL (url) parameter not set" exit 1 fi #txt_record="_acme-challenge.${fulldomain}" #command="curl -k -H 'Authorization: Bearer $apitoken' -H 'Content-Type: application/json' -X POST ${url}/api/dns` + action + ` --data '{\"data\":{ \"domain\":\"${txt_record}\", \"challenge\":\"$challenge\"}}'" #echo "RUNNING COMMAND: $command" #resp=$(curl -k -H "Authorization: Bearer $apitoken" -H 'Content-Type: application/json' -X POST ${url}/api/dns` + action + ` -d "{\"data\":{ \"domain\":\"${txt_record}\", \"challenge\":\"$challenge\"}}") resp=$(curl -k -H "Authorization: Bearer $apitoken" -H 'Content-Type: application/json' -X POST ${url}/api/dns` + action + ` -d "{\"data\":{ \"domain\":\"${fulldomain}\", \"challenge\":\"$challenge\"}}") echo "SCRIPT RESPONSE FROM LEAPI: $resp" if [[ "$resp" == *\"status\":200* ]]; then exit fi exit 1 ` //write config file err = ioutil.WriteFile(configDir+"/dns_"+action+"_kazoo", []byte(script), 0755) if err != nil { return errors.New("Couldn't write dns [" + action + "] script file: " + configDir + "/dns_" + action + "_kazoo") } return err }