Compare commits

...

11 Commits

Author SHA1 Message Date
  Ruel Tmeizeh - RuhNet b0cc266193 Sync, reload sync, cert upload, and process reload fixes. 6 months ago
  Ruel Tmeizeh - RuhNet 1b72d1cae1 API endpoint to tell all servers to run a reload. 6 months ago
  Ruel Tmeizeh - RuhNet 94442a72ef Fixed bug preventing ACL from being able to upload to other servers. 6 months ago
  Ruel Tmeizeh - RuhNet 9c34914f5f README update, more detailed cert error logging. 8 months ago
  Ruel Tmeizeh - RuhNet ebd3a12113 Wildcard certs and Kazoo 10 months ago
  Ruel Tmeizeh - RuhNet 2e73b2c80f Add primary domain to certgroups internal var when starting from scratch with no existing domains.json 2 years ago
  Ruel Tmeizeh - RuhNet 1a3f01939d Fixed certificate locations for multi-domain, and changed SIGHUP to SIGUSR1 for reloading certs. 2 years ago
  Ruel Tmeizeh - RuhNet 0ab7c455e4 Implemented the rest of cert index handling; config changes; few fixes. 2 years ago
  Ruel Tmeizeh - RuhNet 54477ea746 Version bump. 2 years ago
  Ruel Tmeizeh - RuhNet aea85608f6 Added max_domains_per_cert config parameter; changed names of file location config parameters. 2 years ago
  Ruel Tmeizeh - RuhNet 87eff5db2b Changing over to multi-cert system. 2 years ago
11 changed files with 991 additions and 199 deletions
Split View
  1. +30
    -2
      README.md
  2. +239
    -54
      actions.go
  3. +210
    -0
      amqp.go
  4. +295
    -46
      api.go
  5. +1
    -0
      go.mod
  6. +35
    -0
      go.sum
  7. +2
    -2
      leapi.service
  8. +25
    -19
      leapi_config.json.sample
  9. +70
    -73
      main.go
  10. +30
    -3
      sync.go
  11. +54
    -0
      util.go

+ 30
- 2
README.md View File

@ -21,6 +21,9 @@ LEAPI operates in a multi-master configuration. When you add or delete a server
```[GET] https://leapiserver.tld/api/domains``` --- List Domains
```[PUT] https://leapiserver.tld/api/domains/mycoolsite.com``` --- Add New Domain
```[PUT] https://leapiserver.tld/api/domains/%2A.example.com {"domain":"mycoolsite} ``` --- Add New Domain (wildcard--urlencoded)
```[PUT] https://leapiserver.tld/api/domains {"domain":"*.example.com"} ``` --- Add New Domain (wildcard domain in request body)
```[PUT] https://leapiserver.tld/api/domains {"domain":"*.example.com", "check_domain":"web1.example.com"} ``` --- Add New Domain (wildcard domain in request body, with domain to use for checking if cert was installed [otherwise api.example.com will be used for check])
```[DELETE] https://leapiserver.tld/api/domains/mycoolsite.com``` --- Remove Domain
@ -32,9 +35,12 @@ LEAPI operates in a multi-master configuration. When you add or delete a server
- Download the LEAPI binary, or build from source.
- Copy it to ```/opt/leapi```
- You may use the included SystemD service file if you use a SystemD based distribution.
- Edit the ```leapi_config.json``` file for your needs, leaving ```production``` set to ```false``` until setup is complete. Set the ```sync_type``` to either ```ssh``` or ```https```. If you choose ```ssh``` you must create and copy keys and verify you can login to all servers that need to share files between each other. Note: if you enable ```https_server_port``` in the config file, LEAPI needs a certificate to be able to start (it requires the ```tls_chain_path``` and ```tls_key_path```). You can generate a temporary self signed certificate and key with openssl:
- Edit the ```leapi_config.json``` file for your needs, leaving ```production``` set to ```false``` until setup is complete. Set the ```sync_type``` to either ```ssh``` or ```https```. If you choose ```ssh``` you must create and copy keys and verify you can login to all servers that need to share files between each other. Note: if you enable ```https_server_port``` in the config file, LEAPI needs a certificate to be able to start (it requires the ```tls_chain_path_prefix``` and ```tls_key_path_prefix```). You can generate a temporary self signed certificate and key with OpenSSL:
```
openssl req -x509 -nodes -newkey rsa:4096 -keyout privkey.key -out cert.crt -sha256 -days 365
cp cert.crt {tls_chain_path_prefix}00.crt # example: 'cp cert.crt /etc/ssl/leapi/chain00.crt'
cp privkey.key {tls_key_path_prefix}00.key
```
- Copy the config file to ```/opt/leapi``` or ```/etc```.
- Install getssl
@ -46,7 +52,7 @@ curl --silent https://raw.githubusercontent.com/srvrco/getssl/latest/getssl > /o
/opt/leapi/getssl -w /opt/leapi -c mycoolsite.com
```
- Start LEAPI, either from the commandline or with ```systemctl start leapi```
- Add your servers via the LEAPI API:
- Add your servers via the LEAPI API:
(You don't necessarily have to do this on the server itself.)
```
curl -X PUT http://localhost/api/servers/server1.mydomain.com -H 'Authorization: Bearer mySeCrEtKeY'
@ -64,6 +70,28 @@ curl -X PUT http://localhost/api/domains/myothersite.com -H 'Authorization: Bear
curl -X POST http://localhost/api/renew -H 'Authorization: Bearer mySeCrEtKeY'
```
# Installation
## Build
- You must have go v1.16 or later installed.
- clone the repo in the usual way
```
cd leapi
go build
```
## Install
```
mkdir -p /opt/leapi
cp ./leapi /opt/leapi
cp ./leapi.service /etc/systemd/system/
cp ./leapi_config.json.sample /opt/leapi/leapi_config.json
systemctl daemon-reload
```
### Enable the Service
```
systemctl enable leapi
systemctl start leapi
```

+ 239
- 54
actions.go View File

@ -1,4 +1,4 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2024 Ruel Tmeizeh All Rights Reserved
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
@ -6,6 +6,7 @@ import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
@ -19,9 +20,9 @@ import (
func writeDomains() error {
b := new(bytes.Buffer)
err := json.NewEncoder(b).Encode(domains)
err := json.NewEncoder(b).Encode(certgroups)
if err != nil {
return errors.New("Couldn't encode domains list into JSON: " + err.Error())
return errors.New("Couldn't encode certgroups struct into JSON: " + err.Error())
}
err = ioutil.WriteFile(configDir+"/domains.json", b.Bytes(), 0644)
@ -47,7 +48,7 @@ func writeServers() error {
return nil
}
func sendFileToAllServers(filePath string) error {
func sendFileToAllServers(filePath, cert_idx_str string) error {
var theError error
numservers := len(servers)
c := make(chan string)
@ -64,7 +65,7 @@ func sendFileToAllServers(filePath string) error {
}
log.Println("Parallel execution send file to server: " + srv + "...")
err := sendFileToServer(filePath, srv)
err := sendFileToServer(filePath, srv, cert_idx_str)
if err != nil {
log.Println(err.Error())
theError = err
@ -91,17 +92,22 @@ func sendFileToAllServers(filePath string) error {
return theError //if any one or more fail, return an error for it (the last one that fails)
}
func sendFileToServer(filePath, server string) error {
func sendFileToServer(filePath, server, cert_idx_str string) error {
log.Println("Send file " + filePath + " to " + server + " starting...")
_, fileName := path.Split(filePath)
dest := strings.SplitN(fileName, "__", 2)[0] //cert__abcdef1234567890.tmpfile --> "cert"
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/" + dest
log.Println("Send file " + filePath + " to " + url + "...")
//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 {
@ -132,18 +138,20 @@ func sendFileToServer(filePath, server string) error {
log.Println(errorString)
return errors.New(errorString)
}
log.Println("Upload [" + dest + "] to " + server + " success!")
log.Println("Upload [" + fileTypeOrACL + "] to " + server + " success!")
return nil
}
func renew() error {
log.Println("Renew operation initiated...")
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 == appconf.PrimaryDomain { //ignore primary domain
if d == cg.PrimaryDomain { //ignore primary domain
continue
}
domainlist = domainlist + "," + d
@ -157,6 +165,9 @@ func renew() error {
log.Println(domainlist)
}
// GetSSL Expected WebDAV Format: (HTTPS ONLY!)
// ";davs:davsuser:davspassword:{DOMAIN}:443:/path"
//ACL string
aclstring := appconf.LetsEncryptValidationPath
if appconf.SyncType == "ssh" {
@ -165,7 +176,6 @@ func renew() error {
continue
}
aclstring += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.LetsEncryptValidationPath
//aclstring += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/"
}
} else { //file sync type is HTTPS
aclstring += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync"
@ -182,86 +192,82 @@ func renew() error {
}
//Cert and key locations
domain_cert_location := appconf.TLSCertFile
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.TLSCertFile
//domain_cert_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/cert"
domain_cert_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSCertPath + fmt.Sprintf("%02d", cert_idx) + ".crt"
}
} else { //file sync type is HTTPS
domain_cert_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/cert"
// ;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.TLSKeyFile
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.TLSKeyFile
//domain_key_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/key"
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"
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.TLSChainFile
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.TLSChainFile
domain_chain_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/chain"
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"
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_pem_location := appconf.TLSPEMFile
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_pem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMFile
//domain_pem_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/pem"
domain_fullpem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem"
}
} else { //file sync type is HTTPS
domain_pem_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/pem"
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_pem_location)
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 gettssl from environment variables, so write them to config file:
ca_cert_location := appconf.TLSCAFile
//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.TLSCAFile
//ca_cert_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/ca"
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"
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
@ -272,7 +278,7 @@ func renew() error {
//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 + "/api/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"
}
@ -285,17 +291,48 @@ func renew() error {
configFile = "CA=\"" + ca_server + "\"\n"
configFile += "USE_SINGLE_ACL=\"true\"\n"
configFile += "CA_CERT_LOCATION=\"" + appconf.TLSCAFile + "\"\n"
configFile += "CA_CERT_LOCATION=\"" + ca_cert_location + "\"\n"
configFile += "RELOAD_CMD=\"" + reload_command + "\"\n"
configFile += "RENEW_ALLOW=\"" + appconf.RenewAllow + "\"\n"
configFile += "CHECK_REMOTE=\"true\"\n"
configFile += "SERVER_TYPE=\"" + appconf.CheckPort + "\"\n"
configFile += "CHECK_REMOTE_WAIT=\"5\"\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+"/"+appconf.PrimaryDomain+"/getssl.cfg", []byte(configFile), 0644)
err = ioutil.WriteFile(configDir+"/"+cg.PrimaryDomain+"/getssl.cfg", []byte(configFile), 0644)
if err != nil {
return errors.New("Couldn't write getssl config file: " + configDir + "/" + appconf.PrimaryDomain + "/getssl.cfg")
return errors.New("Couldn't write getssl config file: " + configDir + "/" + cg.PrimaryDomain + "/getssl.cfg")
}
if appconf.Debug {
@ -307,29 +344,54 @@ func renew() error {
//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
//cmd = exec.Command(appconf.SrvDir+"/getssl", "-u", "-w", appconf.SrvDir, appconf.PrimaryDomain)
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, appconf.PrimaryDomain)
} else {
cmd = exec.Command(appconf.SrvDir+"/getssl", "-w", appconf.SrvDir, appconf.PrimaryDomain)
cmd = exec.Command(appconf.SrvDir+"/getssl", "-d", "-w", appconf.SrvDir, domain)
}
output, err = cmd.CombinedOutput()
output, err := cmd.CombinedOutput()
log.Println("====================== BEGIN GETSSL OUTPUT: ======================")
log.Println(string(output))
log.Println("======================= END GETSSL OUTPUT ========================")
if err != nil {
log.Println("BEGIN GETSSL OUTPUT:")
log.Println(string(output))
log.Println("END GETSSL OUTPUT")
return errors.New("RENEW: execution of getssl failed: " + err.Error() + " Check log file " + appconf.LogFile + " for more details.")
}
log.Println("BEGIN GETSSL OUTPUT:")
log.Println(string(output))
log.Println("END GETSSL OUTPUT")
return nil
}
@ -359,3 +421,126 @@ func reload() error {
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
}

+ 210
- 0
amqp.go View File

@ -0,0 +1,210 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/google/uuid"
rabbitmq "github.com/wagslane/go-rabbitmq"
)
var publisher *rabbitmq.Publisher
var consumer *rabbitmq.Consumer
var amqpExchange string = "nexus"
var routingKey string = "nexus.dns_challenge_request"
var routingKeyConsume string = "nexus.leapi"
type KzMessage struct {
MsgId string `json:"Msg-ID"`
AppName string `json:"App-Name"`
AppVersion string `json:"App-Version"`
EventName string `json:"Event-Name"`
EventCategory string `json:"Event-Category"`
ServerId string `json:"Server-ID"`
Node string `json:"Node,omitempty"`
Server string `json:"Server,omitempty"`
Domain string `json:"Domain,omitempty"` //e.g. pbx.company.com
ChallengeDomain string `json:"Challenge-Domain,omitempty"` //usually a subdomain like _acme-challenge.pbx.company.com
ChallengePhrase string `json:"Challenge-Phrase,omitempty"`
Servers string `json:"servers,omitempty"`
Domains string `json:"domains,omitempty"`
}
func handleAmqpMsg(d rabbitmq.Delivery) rabbitmq.Action {
if appconf.Debug {
log.Println("AMQP message received: " + string(d.Body))
}
/*
var msg KzMessage
err := json.Unmarshal(d.Body, &msg)
if err != nil {
log.Println("handleAmqpMsg(): Error unmarshalling AMQP message into map[string]interface{}...discarding. Message body: " + string(d.Body) + "\nUnmarshalling error: " + err.Error())
return rabbitmq.NackDiscard
}
err := kazooDnsPublish(ta.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 rabbitmq.Ack
}
func kazooDnsPublish(domain, challenge, eventName string) error {
dnsmsg := KzMessage{
MsgId: uuid.New().String(),
AppName: appname,
AppVersion: version,
EventCategory: "dns_challenge",
EventName: eventName, //"request" or "delete"
ServerId: appname + "@" + appconf.Hostname,
Domain: domain,
ChallengeDomain: "_acme-challenge." + domain,
ChallengePhrase: challenge,
//Node: myHostname,
}
dnsmsg_json, err := json.Marshal(dnsmsg)
if err != nil {
return err
}
err = publisher.Publish(dnsmsg_json,
[]string{routingKey},
rabbitmq.WithPublishOptionsContentType("application/json"),
rabbitmq.WithPublishOptionsExchange(amqpExchange),
)
//return errors.New("Error publishing to exchange '" + amqpExchange + "' with routing key '" + routingKey + "': \n" + string(dnsmsg_json) + " ERROR: " + err.Error())
return err
}
func amqp() {
/////////////////////////////////////////////
// RabbitMQ Setup Connection
log.Println("Connecting to RabbitMQ: " + appconf.AmqpURI)
fmt.Println("Connecting to RabbitMQ: " + appconf.AmqpURI)
amqpConn, err := rabbitmq.NewConn(
appconf.AmqpURI,
rabbitmq.WithConnectionOptionsLogging,
)
if err != nil {
fmt.Println("Unable to initialize RabbitMQ connection: " + err.Error())
log.Fatal("Unable to initialize RabbitMQ connection: " + err.Error())
}
defer amqpConn.Close()
/////////////////////////////////////////////
// RabbitMQ Setup Consumer
log.Println("Starting AMQP consumer...")
consumer, err = rabbitmq.NewConsumer(
amqpConn,
"q_"+appname+"_"+appconf.Hostname,
rabbitmq.WithConsumerOptionsExchangeName(amqpExchange),
rabbitmq.WithConsumerOptionsExchangeKind("topic"),
rabbitmq.WithConsumerOptionsRoutingKey(routingKeyConsume),
rabbitmq.WithConsumerOptionsConsumerName("consumer_"+appname+"_"+appconf.Hostname),
rabbitmq.WithConsumerOptionsQueueAutoDelete,
rabbitmq.WithConsumerOptionsConcurrency(2),
//rabbitmq.WithConsumerOptionsQuorum,
//rabbitmq.WithConsumerOptionsQueueDurable,
//rabbitmq.WithConsumerOptionsExchangeDeclare,
//rabbitmq.WithConsumerOptionsBindingExchangeDurable,
)
if err != nil {
log.Fatal("Unable to initialize RabbitMQ consumer: " + err.Error())
}
defer consumer.Close()
go func() {
err = consumer.Run(handleAmqpMsg) //this is the function that we want to call to consume presence messages
if err != nil {
log.Fatal("Unable to start/run RabbitMQ consumer: " + err.Error())
}
}()
log.Println("Consuming on topic exchange: '" + amqpExchange + "' with routing key: '" + routingKeyConsume + "' using queue: 'consumer_" + appname + "_" + appconf.Hostname + "'.")
/////////////////////////////////////////////
// RabbitMQ Setup Publisher
log.Println("Starting AMQP publisher...")
publisher, err = rabbitmq.NewPublisher(
amqpConn,
rabbitmq.WithPublisherOptionsLogging,
rabbitmq.WithPublisherOptionsExchangeKind("topic"),
rabbitmq.WithPublisherOptionsExchangeName(amqpExchange),
rabbitmq.WithPublisherOptionsExchangeDeclare,
)
if err != nil {
log.Fatal("Unable to initialize RabbitMQ publisher: " + err.Error())
}
defer publisher.Close()
log.Println("AMQP publisher configured on topic exchange: '" + amqpExchange + "' with routing key: '" + routingKey + "'.")
publisher.NotifyReturn(func(r rabbitmq.Return) {
log.Println(fmt.Sprintf("RabbitMQ published message returned from server: %s\n", string(r.Body)))
})
publisher.NotifyPublish(func(c rabbitmq.Confirmation) {
log.Println(fmt.Sprintf("Message confirmed from RabbitMQ server. tag: %v, ack: %v\n", c.DeliveryTag, c.Ack))
})
/////////////////////////////////////////////
// RabbitMQ Publish Test
sigch := make(chan os.Signal, 1)
signal.Notify(sigch, syscall.SIGUSR2)
//create test message to send
pubtest := KzMessage{
MsgId: uuid.New().String(),
AppName: appname,
AppVersion: version,
EventCategory: "dns_challenge",
EventName: "request",
ServerId: appname + "@" + appconf.Hostname,
Domain: "example.com",
ChallengeDomain: "_acme-challenge.pbx.example.com",
ChallengePhrase: "01234abcdef98765fedcba",
//Node: myHostname,
}
msg_pubtest, err := json.Marshal(pubtest)
if err != nil {
log.Println("Unable to marshal pubtest into JSON! Published test message will be blank.")
}
for {
select {
case sig := <-sigch:
log.Println("Received signal " + fmt.Sprintf("%s", sig) + " on pubch channel. Publishing test message... ")
if sig == syscall.SIGUSR2 {
msgFromFile, err := readJsonFile(appconf.PubMessageFile)
if err == nil {
msg_pubtest = msgFromFile
}
log.Println("Publishing test message to exchange '" + amqpExchange + "' with routing key '" + routingKey + "': \n" + string(msg_pubtest))
err = publisher.Publish(msg_pubtest,
[]string{routingKey},
rabbitmq.WithPublishOptionsContentType("application/json"),
rabbitmq.WithPublishOptionsExchange(amqpExchange),
)
if err != nil {
log.Println("Error publishing message: " + err.Error())
} else {
log.Println("Message PUBLISHED.")
}
}
case <-time.After(100 * time.Millisecond):
//case <-time.After(time.Second):
//fmt.Println("tick")
}
}
}

+ 295
- 46
api.go View File

@ -1,41 +1,80 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2024 Ruel Tmeizeh All Rights Reserved
//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 nothingResponse(c echo.Context) error {
return c.NoContent(http.StatusNotFound)
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 uptimeCheck(c echo.Context) error {
if c.Request().Method == http.MethodHead {
return c.NoContent(http.StatusOK)
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.String(http.StatusOK, "{\"up\":true}")
return c.JSON(http.StatusOK, uptime())
return c.JSON(okOut())
}
func apiRenew(c echo.Context) error {
err := renew()
func apiReload(c echo.Context) error {
err := reload()
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
return c.JSON(errorOut(http.StatusInternalServerError, "Error reloading services: "+err.Error()))
}
return c.JSON(okOut())
}
func apiReload(c echo.Context) error {
func apiReloadAll(c echo.Context) error {
err := reload()
if err != nil {
return c.JSON(errorOut(http.StatusInternalServerError, "Error reloading services: "+err.Error()))
@ -43,22 +82,102 @@ func apiReload(c echo.Context) 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.TLSCAFile
filePath = appconf.TLSCAPath + cert_idx_str + ".crt"
case "chain":
filePath = appconf.TLSChainFile
filePath = appconf.TLSChainPath + cert_idx_str + ".crt"
case "key":
filePath = appconf.TLSKeyFile
filePath = appconf.TLSKeyPath + cert_idx_str + ".key"
case "cert":
filePath = appconf.TLSCertFile
filePath = appconf.TLSCertPath + cert_idx_str + ".crt"
case "pem":
filePath = appconf.TLSPEMFile
filePath = appconf.TLSPEMPath + cert_idx_str + ".pem"
default: //ACL
//return c.JSON(errorOut(http.StatusBadRequest, "Invalid filetype/URL."))
filePath = appconf.LetsEncryptValidationPath + "/" + fileType
@ -88,14 +207,15 @@ func apiUpload(c echo.Context) error {
return c.JSON(errorOut(http.StatusInternalServerError, "Could not write file: "+err.Error()))
}
log.Println("Received PUT to " + r.RequestURI)
log.Println("Writing to " + filePath)
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
@ -103,22 +223,22 @@ func apiUploadSync(c echo.Context) error {
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()))
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()))
}
log.Println("Received PUT for sync to " + r.RequestURI)
log.Println("Writing to " + filePath)
err = sendFileToAllServers(filePath)
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()))
@ -131,22 +251,90 @@ func apiListDomains(c echo.Context) error {
var out APIOutput
out.Status = http.StatusOK
out.Message = "domains list"
out.Data = domains
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 _, d := range domains {
if d == domain {
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
domains = append(domains, domain)
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()
@ -163,10 +351,21 @@ func apiPutDomain(c echo.Context) error {
}
//renew cert
err = renew()
err = renew(certgroup_slot)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+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())
@ -174,14 +373,31 @@ func apiPutDomain(c echo.Context) error {
func apiDeleteDomain(c echo.Context) error {
deleteDomain := c.Param("domain")
var newlist []string
for _, d := range domains {
if d != deleteDomain {
newlist = append(newlist, d)
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)
}
}
}
domains = newlist
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()
@ -198,7 +414,7 @@ func apiDeleteDomain(c echo.Context) error {
}
//renew cert
err = renew()
err = renew(certgroup_slot)
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
@ -211,7 +427,10 @@ func apiListServers(c echo.Context) error {
var out APIOutput
out.Status = http.StatusOK
out.Message = "servers list"
out.Data = servers
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)
}
@ -243,10 +462,12 @@ func apiPutServer(c echo.Context) error {
}
//renew cert
err = renew()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
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())
@ -278,10 +499,12 @@ func apiDeleteServer(c echo.Context) error {
}
//renew cert
err = renew()
if err != nil {
log.Println(err.Error())
return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error()))
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())
@ -306,3 +529,29 @@ func apiSync(c echo.Context) 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
}

+ 1
- 0
go.mod View File

@ -6,5 +6,6 @@ require (
github.com/fatih/color v1.13.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/labstack/echo/v4 v4.7.2 // indirect
github.com/wagslane/go-rabbitmq v0.13.0 // indirect
gopkg.in/yaml.v2 v2.2.2 // indirect
)

+ 35
- 0
go.sum View File

@ -6,6 +6,9 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI=
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
@ -18,21 +21,44 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rabbitmq/amqp091-go v1.7.0 h1:V5CF5qPem5OGSnEo8BoSbsDGwejg6VUJsKEdneaoTUo=
github.com/rabbitmq/amqp091-go v1.7.0/go.mod h1:wfClAtY0C7bOHxd3GjmF26jEHn+rR/0B3+YV+Vn9/NI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wagslane/go-rabbitmq v0.13.0 h1:u2JfKbwi3cbxCExKV34RrhKBZjW2HoRwyPTA8pERyrs=
github.com/wagslane/go-rabbitmq v0.13.0/go.mod h1:1sUJ53rrW2AIA7LEp8ymmmebHqqq8ksH/gXIfUP0I0s=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -40,6 +66,7 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
@ -47,7 +74,15 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 2
- 2
leapi.service View File

@ -6,9 +6,9 @@ After=network.target
[Service]
Type=simple
#ExecStartPre=/bin/sh -c 'until ping -c1 8.8.8.8 ; do sleep 1; done;'
#ExecStartPre=
ExecStart=/opt/leapi/leapi
ExecReload=/bin/kill -HUP $MAINPID
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
[Install]


+ 25
- 19
leapi_config.json.sample View File

@ -1,27 +1,33 @@
//RuhNet LEAPI Config file
//configDir set by environment variable LEAPI_CONFDIR, otherwise assumed to be /opt/leapi or ./
{
"hostname":"web1.mydomain.net", //hostname or IP of this particular server; must match the server you add to LEAPI. You can use "-" to use the system hostname (must be resolvable by other LEAPI systems).
"primary_domain":"mydomain.net", //the main base domain that is always present
"srv_dir":"/opt/leapi", //LEAPI installed directory
"sync_type":"https", //method of transferring files between LEAPI hosts. "ssh" or "https"
"username":"leapi", //the username to use for file transfer (applies to either http or ssh)
"hostname":"web1.mydomain.net", //hostname or IP of this particular server; MUST match the server you add to LEAPI. You can use "-" to use the system hostname (must be resolvable by other LEAPI systems).
"primary_domain":"mydomain.net", //the main base domain that is always present; can NOT be a wildcard domain
"srv_dir":"/opt/leapi", //LEAPI installed directory
"sync_type":"https", //method of transferring files between LEAPI hosts. "ssh" or "https"
"username":"leapi", //the username to use for file transfer (applies to either http or ssh)
"secret_key":"SecReT_KeY-4API-AuThenTiCaTiON",
"log_file":"/var/log/leapi.log",
"debug":false,
"frontend_url":"admin.mydomain.net", //the frontend URL, if any (for CORS). Use "-" if none.
"http_server_port":"80", //set to 80 if you aren't using a separate web server
"https_server_port":"-", //set to "-" to disable HTTPS (mainly useful for initial setup)
"tls_cert_path":"/etc/ssl/cert.crt",
"tls_key_path":"/etc/ssl/privkey.key",
"tls_chain_path":"/etc/ssl/chain.crt",
"tls_pem_path":"/etc/ssl/domain.pem",
"tls_ca_path":"/etc/ssl/ca.crt",
"letsencrypt_validation_path":"-", //if "-", LEAPI handles this and you don't use a separate web server
"renew_allow_days":"70",
"reload_command":"systemctl reload leapi ; systemctl restart nginx",
"check_port":"443", //the port/service to check to verify cert installation (https/imap/imaps/xmpp/ftp/smtp)
"production":false, //if false, the staging LE server will be used. Set true to use the rate limited real server.
"secret_key":"SecReT_KeY-4API-AuThenTiCaTiON"
"frontend_url":"admin.mydomain.net", //the frontend URL, if any (for CORS). Use "-" if none.
"http_server_port":"-", //set to 80 if you are not using a separate web server or proxy. "-" will assume port 80.
"https_server_enable":true, //set to false to disable HTTPS listener (for initial setup, or for using a separate web server/proxy)
"https_server_port":"-", //the port your HTTPS server is running on, whether LEAPI or an external web server/proxy. Set to "-" for default (port 443)
"tls_cert_path_prefix":"/etc/ssl/leapi/cert", //file paths DO NOT INCLUDE EXTENSION. "/etc/ssl/cert" will write files "/etc/ssl/cert01.crt", "/etc/ssl/cert02.crt", etc.
"tls_key_path_prefix":"/etc/ssl/leapi/privkey",
"tls_chain_path_prefix":"/etc/ssl/leapi/chain",
"tls_fullpem_path_prefix":"/etc/ssl/leapi/fullpem",
"tls_ca_path_prefix":"/etc/ssl/leapi/ca",
"max_domains_per_cert":100, //100 max
"enable_kazoo_amqp":false, //enable custom communication with Kazoo over AMQP for DNS record creation
"kazoo_amqp_uri":"amqp://guest:guest@localhost:5672", //AMQP_URI
"amqp_testmessage_path":"/tmp/message.json", //path to JSON file to publish as test on receiving SIGUSR2
"letsencrypt_validation_path":"-", //if "-", LEAPI handles this IF you don't use a separate web server
"renew_allow_days":"60",
"check_port":"443", //the port/service to check to verify cert installation (https/imap/imaps/xmpp/ftp/smtp)
"check_wait_time":10, //how long to delay (in seconds) before checking the port (allow time for service restarts)
"production":false, //if false, the staging LE server will be used. Set true to use the rate limited real server.
"reload_command":"systemctl reload leapi ; systemctl restart nginx"
}

+ 70
- 73
main.go View File

@ -1,4 +1,4 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2024 Ruel Tmeizeh All Rights Reserved
// LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
@ -18,12 +18,12 @@ import (
"time"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
const version string = "1.1.1"
const appname string = "leapi"
const version string = "1.3.4"
const serverVersion string = "RuhNet LE API v" + version
const apiVersion int = 1
const website string = "https://ruhnet.co"
@ -35,15 +35,15 @@ var appconf LEAPIConfig
var startupTime time.Time
var configDir string
var domains []string
var certgroups []CertGroup
var servers []string
var syncScheme string = "http://"
var syncPort string
const banner = `
____ __ _ _ __
/ ___\ __ __/ /_ / \ / /__ __/ /_
/ /_/ // /_/ / _ \/ / \/ //__\_ __/
--____ __ _ _ __
-/ ___\ __ __/ /_ / \ / /__ __/ /_
/ /_/ // /_/ / _ \/ / \/ //__\_ __/
/_/ \_\\ ___/_/ /_/_/ \__/ \__,/_/ %s
_____________________________________________________
`
@ -60,11 +60,13 @@ type LEAPIConfig struct {
Debug bool `json:"debug"`
HTTP_ServerPort string `json:"http_server_port"`
HTTPS_ServerPort string `json:"https_server_port"`
TLSCertFile string `json:"tls_cert_path"`
TLSKeyFile string `json:"tls_key_path"`
TLSChainFile string `json:"tls_chain_path"`
TLSPEMFile string `json:"tls_pem_path"`
TLSCAFile string `json:"tls_ca_path"`
HTTPS_ServerEnable bool `json:"https_server_enable"`
TLSCertPath string `json:"tls_cert_path_prefix"`
TLSKeyPath string `json:"tls_key_path_prefix"`
TLSChainPath string `json:"tls_chain_path_prefix"`
TLSPEMPath string `json:"tls_fullpem_path_prefix"`
TLSCAPath string `json:"tls_ca_path_prefix"`
MaxDomainsPerCert int `json:"max_domains_per_cert"` //can't have more than 100 names on a single cert
FrontEndURL string `json:"frontend_url"`
PrimaryDomain string `json:"primary_domain"`
LetsEncryptValidationPath string `json:"letsencrypt_validation_path"`
@ -73,18 +75,16 @@ type LEAPIConfig struct {
SecretKey string `json:"secret_key"`
Production bool `json:"production"`
CheckPort string `json:"check_port"`
CheckWaitSec int `json:"check_wait_time"`
KazooAMQP bool `json:"enable_kazoo_amqp"`
AmqpURI string `json:"kazoo_amqp_uri"`
PubMessageFile string `json:"amqp_testmessage_path"`
}
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 []string `json:"data,omitempty"`
type CertGroup struct {
PrimaryDomain string `json:"primary_domain"`
Wildcard bool `json:"wildcard"`
Domains []string `json:"domains"`
}
type keypairReloader struct {
@ -153,7 +153,7 @@ func main() {
//Startup Banner
fmt.Printf(banner, website)
fmt.Println(serverVersion + "\n")
fmt.Printf(serverVersion + "\n\n")
//read domains file
domainsFile := configDir + "/domains.json"
@ -164,7 +164,7 @@ func main() {
}
defer jsonFile.Close()
fileBytes, err := ioutil.ReadAll(jsonFile)
err = json.Unmarshal(fileBytes, &domains)
err = json.Unmarshal(fileBytes, &certgroups)
if err != nil {
log.Fatal("Could not parse domains.json file: " + err.Error())
}
@ -185,8 +185,22 @@ func main() {
}
}
//set ports to defaults if "-" or zero
if appconf.HTTP_ServerPort == "-" || appconf.HTTP_ServerPort == "0" {
appconf.HTTP_ServerPort = "80"
}
if appconf.HTTPS_ServerPort == "-" || appconf.HTTPS_ServerPort == "0" {
appconf.HTTPS_ServerPort = "443"
}
//set sync port
syncPort = appconf.HTTP_ServerPort
if appconf.LetsEncryptValidationPath == "-" {
if appconf.SyncType == "https" {
syncPort = appconf.HTTPS_ServerPort
syncScheme = "https://"
}
if appconf.LetsEncryptValidationPath == "-" || appconf.LetsEncryptValidationPath == "" {
appconf.LetsEncryptValidationPath = appconf.SrvDir + "/acme-challenge"
}
@ -244,11 +258,14 @@ func main() {
/////////////////////////////////
// API Routes //
/////////////////////////////////
api.OPTIONS("/certgroups", apiListCertGroups)
api.GET("/certgroups", apiListCertGroups)
api.OPTIONS("/domains", apiListDomains)
api.GET("/domains", apiListDomains)
api.OPTIONS("/domains/:domain", apiPutDomain)
api.PUT("/domains/:domain", apiPutDomain)
api.PUT("/domains", apiPutDomain)
api.DELETE("/domains/:domain", apiDeleteDomain)
api.OPTIONS("/servers", apiListServers)
@ -266,29 +283,47 @@ func main() {
api.OPTIONS("/reload", apiReload)
api.POST("/reload", apiReload)
api.OPTIONS("/reloadall", apiReloadAll)
api.POST("/reloadall", apiReloadAll)
api.POST("/dnsadd", apiAddDns)
api.POST("/dnsdel", apiDelDns)
apiFile.OPTIONS("/upload/:fileType", apiUpload)
apiFile.PUT("/upload/:fileType", apiUpload)
apiFile.OPTIONS("/upload/:fileType/:cert_idx", apiUpload)
apiFile.PUT("/upload/:fileType/:cert_idx", apiUpload)
apiFile.OPTIONS("/sync/:fileType", apiUploadSync)
apiFile.PUT("/sync/:fileType", apiUploadSync)
apiFile.OPTIONS("/sync/:fileType/:cert_idx", apiUploadSync)
apiFile.PUT("/sync/:fileType/:cert_idx", apiUploadSync)
if appconf.KazooAMQP {
/////////////////////////////////////////////
// AMQP System:
go amqp()
}
/////////////////////////////////////////////
// HTTP SERVERS CONFIG:
//TLS Server
if appconf.HTTPS_ServerPort != "-" { //disable HTTPS if port is zero
if appconf.HTTPS_ServerEnable {
syncScheme = "https://"
syncPort = appconf.HTTPS_ServerPort
keyPath := appconf.TLSKeyPath + "00.key"
certPath := appconf.TLSChainPath + "00.crt"
//certPair, err := tls.LoadX509KeyPair(appconf.TLSCertificateFile, appconf.TLSKeyFile)
if !fileExists(appconf.TLSChainFile) || !fileExists(appconf.TLSKeyFile) {
fmt.Println("Provided certificate and/or key file does not exist! Terminating.")
log.Fatal("Provided certificate and/or key file does not exist! Terminating.")
//certPair, err := tls.LoadX509KeyPair(appconf.TLSCertificateFile, appconf.TLSKeyPath)
if !fileExists(certPath) || !fileExists(keyPath) {
fmt.Println("Provided certificate " + appconf.TLSChainPath + "00.crt and/or key file " + appconf.TLSKeyPath + "00.key does not exist! Terminating.")
log.Fatal("Provided certificate " + appconf.TLSChainPath + "00.crt and/or key file " + appconf.TLSKeyPath + "00.key does not exist! Terminating.")
}
//Create loader for cert files
kpr, err := NewKeypairReloader(appconf.TLSChainFile, appconf.TLSKeyFile)
kpr, err := NewKeypairReloader(certPath, keyPath)
if err != nil {
log.Fatal(err)
}
@ -355,7 +390,7 @@ func (f *LEAPIConfig) checkConfig() {
}
}
//This middleware adds headers to the response, for server version and CORS origin.
// This middleware adds headers to the response, for server version and CORS origin.
func serverHeaders(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderServer, serverVersion)
@ -395,10 +430,10 @@ func NewKeypairReloader(certPath, keyPath string) (*keypairReloader, error) {
result.cert = &cert
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGHUP)
signal.Notify(c, syscall.SIGUSR1)
for range c {
log.Printf("Received SIGHUP, reloading TLS certificate and key from %q and %q", appconf.TLSChainFile, appconf.TLSKeyFile)
fmt.Printf("Received SIGHUP, reloading TLS certificate and key from %q and %q\n", appconf.TLSChainFile, appconf.TLSKeyFile)
log.Printf("Received SIGHUP, reloading TLS certificate and key from %q and %q", certPath, keyPath)
fmt.Printf("Received SIGHUP, reloading TLS certificate and key from %q and %q\n", certPath, keyPath)
if err := result.maybeReload(); err != nil {
log.Printf("Keeping old TLS certificate because the new one could not be loaded: %v", err)
fmt.Printf("Keeping old TLS certificate because the new one could not be loaded: %v", err)
@ -426,41 +461,3 @@ func (kpr *keypairReloader) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tl
return kpr.cert, nil
}
}
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
}
func generateUUID() string {
return strings.Replace(uuid.New().String(), "-", "", -1)
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
func uptime() UpOut {
uptime := fmt.Sprintf("%s", time.Since(startupTime))
out := UpOut{
Up: true,
StartTime: startupTime,
Uptime: uptime,
}
return out
}

+ 30
- 3
sync.go View File

@ -1,4 +1,4 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2024 Ruel Tmeizeh All Rights Reserved
// LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
@ -123,7 +123,15 @@ func syncServersFromHost(host string) error {
log.Println(err.Error())
return errors.New("Couldn't parse response body from host " + host + ": " + err.Error())
}
servers = result.Data
if len(result.Data) > 0 { // if there is data to sync, reset our local server's cache
servers = nil
}
for _, data := range result.Data {
switch server := data.(type) {
case string:
servers = append(servers, server)
}
}
err = writeServers()
if err != nil {
@ -134,6 +142,7 @@ func syncServersFromHost(host string) error {
return nil
}
// Get domains from server and write what is received to this server's domains.json file.
func syncDomainsFromHost(host string) error {
var theError error
req, err := http.NewRequest("GET", syncScheme+host+":"+syncPort+"/api/domains", nil)
@ -171,7 +180,25 @@ func syncDomainsFromHost(host string) error {
log.Println(err.Error())
return errors.New("Couldn't parse response body from host " + host + ": " + err.Error())
}
domains = result.Data
if len(result.Data) > 0 { // if there is data to sync, reset our local server's cache
certgroups = nil
}
for _, data := range result.Data {
//fmt.Printf("\ndomain sync data: %+v\n", data)
var cg CertGroup
switch cg_raw := data.(type) {
case interface{}: // put into json string, then from there into a CertGroup struct
cg_json, err := json.Marshal(cg_raw)
if err != nil {
log.Println(err.Error())
}
err = json.Unmarshal(cg_json, &cg)
if err != nil {
log.Println(err.Error())
}
certgroups = append(certgroups, cg)
}
}
err = writeDomains()
if err != nil {


+ 54
- 0
util.go View File

@ -0,0 +1,54 @@
//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
func readJsonFile(filePath string) (jsonBytes []byte, err error) {
jsonFile, err := os.Open(filePath)
if err != nil {
log.Println("Could not open JSON file: " + filePath + "\n" + err.Error())
return jsonBytes, err
}
defer jsonFile.Close()
fileBytes, _ := ioutil.ReadAll(jsonFile)
//strip out // comments from file:
re := regexp.MustCompile(`([\s]//.*)|(^//.*)`)
fileCleanedBytes := re.ReplaceAll(fileBytes, nil)
return fileCleanedBytes, err
}
func generateUUID() string {
return strings.Replace(uuid.New().String(), "-", "", -1)
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
func uptime() UpOut {
uptime := fmt.Sprintf("%s", time.Since(startupTime))
out := UpOut{
Up: true,
StartTime: startupTime,
Uptime: uptime,
}
return out
}

Loading…
Cancel
Save