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

463 lines
14 KiB

// LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2025 Ruel Tmeizeh All Rights Reserved
package main
import (
"crypto/tls"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"reflect"
"regexp"
"strings"
"sync"
"syscall"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
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"
const myUserAgent string = "RuhNet LE Cluster API Controller"
const timeout time.Duration = time.Duration(30 * time.Second) //Timeout for outbound requests. Adjust as needed.
var appconf LEAPIConfig
var startupTime time.Time
var configDir string
var certgroups []CertGroup
var servers []string
var syncScheme string = "http://"
var syncPort string
const banner = `
--⟶____ __ _ _ __
-⟶/ ___\ __ __/ /_ / \ / /__ __/ /_
⟶/ /_/ // /_/ / _ \/ / \/ //__\_ __/
/_/ \_\\ ___/_/ /_/_/ \__/ \__,/_/ %s
_____________________________________________________
`
//////////////////////////
//Data Structs:
type LEAPIConfig struct {
Hostname string `json:"hostname"`
SyncType string `json:"sync_type"`
Username string `json:"username"`
SrvDir string `json:"srv_dir"`
LogFile string `json:"log_file"`
Debug bool `json:"debug"`
HTTP_ServerPort string `json:"http_server_port"`
HTTPS_ServerPort string `json:"https_server_port"`
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"`
ReloadCommand string `json:"reload_command"`
RenewAllow string `json:"renew_allow_days"`
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 CertGroup struct {
PrimaryDomain string `json:"primary_domain"`
Wildcard bool `json:"wildcard"`
Domains []string `json:"domains"`
}
type keypairReloader struct {
certMu sync.RWMutex
cert *tls.Certificate
certPath string
keyPath string
}
func main() {
/////////////////////////////////////////////
startupTime = time.Now()
//Read config:
configFilename := "leapi_config.json"
configDir = os.Getenv("LEAPI_CONFDIR")
if configDir == "" {
confDirs := []string{
"/usr/local/etc",
"/opt/leapi",
"/opt/leapi/etc",
"/var/lib/leapi",
"/etc",
}
configDir = "." //the fallback
for _, cd := range confDirs {
if _, err := os.Stat(cd + "/" + configFilename); os.IsNotExist(err) { //doesn't exist...
continue //..so check next one
}
configDir = cd
}
}
configFile := configDir + "/" + configFilename
jsonFile, err := os.Open(configFile)
if err != nil {
log.Fatal("Could not open config file: " + configFile + "\n" + err.Error())
}
defer jsonFile.Close()
fileBytes, _ := ioutil.ReadAll(jsonFile)
//strip out // comments from config file:
re := regexp.MustCompile(`([\s]//.*)|(^//.*)`)
fileCleanedBytes := re.ReplaceAll(fileBytes, nil)
err = json.Unmarshal(fileCleanedBytes, &appconf) //populate the config struct with JSON data from the config file
if err != nil {
log.Fatal("Could not parse config file: " + configFile + "\n" + err.Error())
}
appconf.checkConfig()
log.Println("Configuration OK, starting LEAPI...")
fmt.Println()
leapiLogFile, err := os.OpenFile(appconf.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664)
if err != nil {
log.Println("Could not open log file: " + appconf.LogFile + "\n" + err.Error())
leapiLogFile, err = os.OpenFile("/tmp/leapi.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664)
if err != nil {
log.Fatal("Can't open even /tmp log file!\n" + err.Error())
}
}
defer leapiLogFile.Close()
//set other logging to same file
log.SetOutput(leapiLogFile)
//Startup Banner
fmt.Printf(banner, website)
fmt.Printf(serverVersion + "\n\n")
//read domains file
domainsFile := configDir + "/domains.json"
if fileExists(domainsFile) {
jsonFile, err = os.Open(domainsFile)
if err != nil {
log.Fatal("Could not open domains.json file: " + err.Error())
}
defer jsonFile.Close()
fileBytes, err := ioutil.ReadAll(jsonFile)
err = json.Unmarshal(fileBytes, &certgroups)
if err != nil {
log.Fatal("Could not parse domains.json file: " + err.Error())
}
}
//read servers file
serversFile := configDir + "/servers.json"
if fileExists(serversFile) {
jsonFile, err = os.Open(serversFile)
if err != nil {
log.Fatal("Could not open servers.json file: " + err.Error())
}
defer jsonFile.Close()
fileBytes, err := ioutil.ReadAll(jsonFile)
err = json.Unmarshal(fileBytes, &servers)
if err != nil {
log.Fatal("Could not parse servers.json file: " + err.Error())
}
}
//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.SyncType == "https" {
syncPort = appconf.HTTPS_ServerPort
syncScheme = "https://"
}
if appconf.LetsEncryptValidationPath == "-" || appconf.LetsEncryptValidationPath == "" {
appconf.LetsEncryptValidationPath = appconf.SrvDir + "/acme-challenge"
}
if appconf.Hostname == "-" {
hostname, err := os.Hostname()
if err != nil {
log.Fatal("Hostname could not be auto-detected from system: " + err.Error())
}
appconf.Hostname = hostname
}
fmt.Println("My hostname: " + appconf.Hostname)
/////////////////////////////////////////////
//Echo config:
e := echo.New() // Echo instance
e.HideBanner = true
e.Use(middleware.Recover())
//e.Logger.SetLevel(stdLog.DEBUG)
//e.Debug = true
//e.Use(middleware.Logger())
/*
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Output: leapiLogFile,
}))
*/
/////////////////////////////////////////////
// ROUTE GROUPS
api := e.Group("/api") //API routes
apiFile := e.Group("/api/file") //API routes
/////////////////////////////////////////////
/////////////////////////////////////////////
// MIDDLEWARE
//Add server header and CORS
e.Use(serverHeaders)
//Auth API routes
api.Use(middleware.KeyAuth(apiKeyAuth))
apiFile.Use(middleware.BasicAuth(apiBasicAuth))
/////////////////////////////////////////////
/////////////////////////////////////////////
// ROUTES:
e.GET("/", nothingResponse)
e.HEAD("/", uptimeCheck)
e.HEAD("/up", uptimeCheck)
e.GET("/up", uptimeCheck)
e.HEAD("/_up", uptimeCheck)
e.GET("/_up", uptimeCheck)
e.Static("/.well-known/acme-challenge", appconf.LetsEncryptValidationPath) //Lets Encrypt validation path
/////////////////////////////////
// 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)
api.GET("/servers", apiListServers)
api.OPTIONS("/servers/:server", apiPutServer)
api.PUT("/servers/:server", apiPutServer)
api.DELETE("/servers/:server", apiDeleteServer)
api.OPTIONS("/sync/:host", apiSync)
api.POST("/sync/:host", apiSync)
api.OPTIONS("/renew", apiRenew)
api.POST("/renew", apiRenew)
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_ServerEnable {
syncScheme = "https://"
syncPort = appconf.HTTPS_ServerPort
keyPath := appconf.TLSKeyPath + "00.key"
certPath := appconf.TLSChainPath + "00.crt"
//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(certPath, keyPath)
if err != nil {
log.Fatal(err)
}
tlsConfig := &tls.Config{
//MinVersion: tls.VersionTLS10,
MinVersion: tls.VersionTLS12,
//CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP521, tls.CurveP384, tls.CurveP256},
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, //these two have fast assembly implementations
PreferServerCipherSuites: true,
//Certificates: []tls.Certificate{certPair},
//Use loader instead of certPair
GetCertificate: kpr.GetCertificateFunc(),
}
srvTLS := &http.Server{
Addr: ":" + appconf.HTTPS_ServerPort,
ReadTimeout: 180 * time.Second,
WriteTimeout: 180 * time.Second,
IdleTimeout: 180 * time.Second,
TLSConfig: tlsConfig,
}
//Start TLS Server
go func(c *echo.Echo) {
e.Logger.Fatal(e.StartServer(srvTLS))
}(e)
}
//HTTP Server
srvHTTP := &http.Server{
Addr: ":" + appconf.HTTP_ServerPort,
ReadTimeout: 180 * time.Second,
WriteTimeout: 180 * time.Second,
IdleTimeout: 180 * time.Second,
}
//Start HTTP Server
e.Logger.Fatal(e.StartServer(srvHTTP))
} //func main()
func (f *LEAPIConfig) checkConfig() {
var invalid bool
val := reflect.ValueOf(f).Elem()
fmt.Println()
for i := 0; i < val.NumField(); i++ {
valueField := val.Field(i)
if valueField.Interface() == "" || valueField.Interface() == nil || valueField.Interface() == 0 {
if !invalid {
log.Println("===========| ERRORS IN 'leapi_config.json' CONFIG FILE: |================")
color.Red("--------------------------------------------------------------------------------")
color.Red(" ===========| ERRORS IN 'leapi_config.json' CONFIG FILE: |================")
color.Red("--------------------------------------------------------------------------------")
}
invalid = true
log.Printf(" - Required config item '%s' missing or invalid.\n", val.Type().Field(i).Tag.Get("json"))
fmt.Printf(" - Required config item '%s' missing or invalid.\n", val.Type().Field(i).Tag.Get("json"))
}
}
if invalid {
color.Red("--------------------------------------------------------------------------------")
log.Fatal("Exiting!")
}
}
// 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)
c.Response().Header().Set("Access-Control-Allow-Origin", appconf.FrontEndURL)
c.Response().Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE")
//c.Response().Header().Set("Access-Control-Allow-Headers", appconf.AllowedHeaders)
c.Response().Header().Set("Access-Control-Allow-Headers",
strings.Join([]string{
echo.HeaderOrigin,
"X-Auth-Token",
echo.HeaderContentType,
echo.HeaderAccept,
echo.HeaderAuthorization,
}, ", "))
return next(c)
}
}
func apiKeyAuth(key string, c echo.Context) (bool, error) {
return (key == appconf.SecretKey), nil
}
func apiBasicAuth(username, password string, c echo.Context) (bool, error) {
return ((username == appconf.Username) && (password == appconf.SecretKey)), nil
}
func NewKeypairReloader(certPath, keyPath string) (*keypairReloader, error) {
result := &keypairReloader{
certPath: certPath,
keyPath: keyPath,
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
result.cert = &cert
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
for range c {
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)
}
}
}()
return result, nil
}
func (kpr *keypairReloader) maybeReload() error {
newCert, err := tls.LoadX509KeyPair(kpr.certPath, kpr.keyPath)
if err != nil {
return err
}
kpr.certMu.Lock()
defer kpr.certMu.Unlock()
kpr.cert = &newCert
return nil
}
func (kpr *keypairReloader) GetCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return func(clientHello *tls.ClientHelloInfo) (*tls.Certificate, error) {
kpr.certMu.RLock()
defer kpr.certMu.RUnlock()
return kpr.cert, nil
}
}