//LEAPI - ACME Certificate Renewal Control API - Copyright 2022-2024 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/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) const version string = "1.1.1" 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 domains []string 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"` 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"` 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"` } 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 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.Println(serverVersion + "\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, &domains) 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()) } } syncPort = appconf.HTTP_ServerPort if 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("/domains", apiListDomains) api.GET("/domains", apiListDomains) api.OPTIONS("/domains/:domain", apiPutDomain) api.PUT("/domains/:domain", 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) apiFile.OPTIONS("/upload/:fileType", apiUpload) apiFile.PUT("/upload/:fileType", apiUpload) apiFile.OPTIONS("/sync/:fileType", apiUploadSync) apiFile.PUT("/sync/:fileType", apiUploadSync) ///////////////////////////////////////////// // HTTP SERVERS CONFIG: //TLS Server if appconf.HTTPS_ServerPort != "-" { //disable HTTPS if port is zero syncScheme = "https://" syncPort = appconf.HTTPS_ServerPort //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.") } //Create loader for cert files kpr, err := NewKeypairReloader(appconf.TLSChainFile, appconf.TLSKeyFile) 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.SIGHUP) 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) 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 } } 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 }