commit cbd70131a98c4b646cfa23e107ed072eef8ad5e5 Author: RuhNet Date: Tue Apr 19 18:54:53 2022 -0400 Initial Commit - Version 1.0 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..623041f --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module leapi + +go 1.16 + +require ( + github.com/fatih/color v1.13.0 // indirect + github.com/labstack/echo/v4 v4.7.2 // indirect + gopkg.in/yaml.v2 v2.2.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..821493f --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +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= +github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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= +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/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +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/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-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.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= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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= diff --git a/leapi_config.json b/leapi_config.json new file mode 100644 index 0000000..d154494 --- /dev/null +++ b/leapi_config.json @@ -0,0 +1,21 @@ +//RuhNet LEAPI Config file +//configDir set by environment variable LEAPI_CONFDIR, otherwise assumed to be /opt/leapi or ./ +{ + "hostname":"web1.mydomain.net", //hostname of this particular server + "primary_domain":"mydomain.net", //the main base domain that is always present + "srv_dir":"/opt/leapi", //LEAPI installed directory + "user":"root", //the username to use for SSH + "log_file":"/var/log/leapi.log", + "frontend_url":"admin.mydomain.net", //the frontend URL, if any (for CORS). Use "-" if none. + "http_server_port":"8080", //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 + "reload_command":"systemctl restart nginx" //needs to match on all servers +} + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..a8f2f9c --- /dev/null +++ b/main.go @@ -0,0 +1,896 @@ +//LEAPI Voice Control API - Copyright 2022 Ruel Tmeizeh All Rights Reserved + +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "os/exec" + "os/signal" + "reflect" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/fatih/color" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +const version string = "1.0" +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 leapiconf 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"` + Username string `json:"user"` + SrvDir string `json:"srv_dir"` + LogFile string `json:"log_file"` + 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"` +} + +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) + //fmt.Println(string(fileCleanedBytes)) + + err = json.Unmarshal(fileCleanedBytes, &leapiconf) //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()) + } + + leapiconf.checkConfig() + + log.Println("Configuration OK, starting LEAPI...") + fmt.Println() + + leapiLogFile, err := os.OpenFile(leapiconf.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0664) + if err != nil { + log.Println("Could not open log file: " + leapiconf.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 = leapiconf.HTTP_ServerPort + if leapiconf.LetsEncryptValidationPath == "-" { + leapiconf.LetsEncryptValidationPath = leapiconf.SrvDir + "/acme-challenge" + } + + ///////////////////////////////////////////// + //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, + })) + */ + + ///////////////////////////////////////////// + // MIDDLEWARE + //Add server header and CORS + e.Use(serverHeaders) + ///////////////////////////////////////////// + + ///////////////////////////////////////////// + // ROUTES: + + 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", leapiconf.LetsEncryptValidationPath) //Lets Encrypt validation path + + ///////////////////////////////// + // API Routes // + ///////////////////////////////// + + e.OPTIONS("/domains", apiListDomains) + e.GET("/domains", apiListDomains) + e.OPTIONS("/domains/:domain", apiPutDomain) + e.PUT("/domains/:domain", apiPutDomain) + e.DELETE("/domains/:domain", apiDeleteDomain) + + e.OPTIONS("/servers", apiListServers) + e.GET("/servers", apiListServers) + e.OPTIONS("/servers/:server", apiPutServer) + e.PUT("/servers/:server", apiPutServer) + e.DELETE("/servers/:server", apiDeleteServer) + + e.OPTIONS("/sync/:host", apiSync) + e.POST("/sync/:host", apiSync) + + e.OPTIONS("/renew", apiRenew) + e.POST("/renew", apiRenew) + + ///////////////////////////////////////////// + // HTTP SERVERS CONFIG: + + //TLS Server + if leapiconf.HTTPS_ServerPort != "-" { //disable HTTPS if port is zero + + syncScheme = "https://" + syncPort = leapiconf.HTTPS_ServerPort + + //certPair, err := tls.LoadX509KeyPair(leapiconf.TLSCertificateFile, leapiconf.TLSKeyFile) + if !fileExists(leapiconf.TLSCertFile) || !fileExists(leapiconf.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(leapiconf.TLSCertFile, leapiconf.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: ":" + leapiconf.HTTPS_ServerPort, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * 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: ":" + leapiconf.HTTP_ServerPort, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 60 * 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", leapiconf.FrontEndURL) + c.Response().Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") + //c.Response().Header().Set("Access-Control-Allow-Headers", leapiconf.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 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", leapiconf.TLSCertFile, leapiconf.TLSKeyFile) + if err := result.maybeReload(); err != nil { + log.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 fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +func writeDomains() error { + b := new(bytes.Buffer) + err := json.NewEncoder(b).Encode(domains) + if err != nil { + return errors.New("Couldn't encode domains list into JSON: " + err.Error()) + } + + err = ioutil.WriteFile(configDir+"/domains.json", b.Bytes(), 0644) + if err != nil { + return errors.New("Couldn't write domains file: " + configDir + "/domains.json") + } + + return nil +} + +func writeServers() error { + b := new(bytes.Buffer) + err := json.NewEncoder(b).Encode(servers) + if err != nil { + return errors.New("Couldn't encode servers list into JSON: " + err.Error()) + } + + err = ioutil.WriteFile(configDir+"/servers.json", b.Bytes(), 0644) + if err != nil { + return errors.New("Couldn't write servers file: " + configDir + "/servers.json") + } + + return nil +} + +func syncAllServers() error { + var theError error + for _, server := range servers { + if server == leapiconf.Hostname { //don't send request to myself + continue + } + //Make http requests to each other servers' /sync endpoints + // https://server.tld:port/sync + req, err := http.NewRequest("POST", syncScheme+server+":"+syncPort+"/sync", nil) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't create new HTTP sync request for server: " + server) + } + req.Close = true + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", myUserAgent) + client := &http.Client{Timeout: timeout} + response, err := client.Do(req) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't perform HTTP sync request 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 { + theError = errors.New("Problem syncing to server " + server + ". Status code: " + strconv.Itoa(response.StatusCode) + " Body: " + string(body)) + log.Println(theError.Error()) + } + } + if theError != nil { + return theError + } + return nil +} + +func syncServersFromHost(host string) error { + var theError error + req, err := http.NewRequest("GET", syncScheme+host+":"+syncPort+"/servers", nil) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't create new HTTP request for syncing servers from host: " + host) + } + req.Close = true + req.Header.Set("User-Agent", myUserAgent) + client := &http.Client{Timeout: timeout} + response, err := client.Do(req) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't perform HTTP server sync request to host: " + host) + } + body, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't parse response body from server sync request to server: " + host) + } + if response.StatusCode != 200 { + theError = errors.New("Problem syncing servers from host " + host + ". Status code: " + strconv.Itoa(response.StatusCode) + " Body: " + string(body)) + log.Println(theError.Error()) + return theError + } + + err = json.Unmarshal(body, &servers) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't store response body from host " + host + " into servers list: " + err.Error()) + } + + err = writeServers() + if err != nil { + log.Println(err.Error()) + return err + } + + return nil +} + +func syncDomainsFromHost(host string) error { + var theError error + req, err := http.NewRequest("GET", syncScheme+host+":"+syncPort+"/domains", nil) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't create new HTTP request for syncing domains from host: " + host) + } + req.Close = true + req.Header.Set("User-Agent", myUserAgent) + client := &http.Client{Timeout: timeout} + response, err := client.Do(req) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't perform HTTP domain sync request to host: " + host) + } + body, err := ioutil.ReadAll(response.Body) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't parse response body from domain sync request to server: " + host) + } + if response.StatusCode != 200 { + theError = errors.New("Problem syncing domains from host " + host + ". Status code: " + strconv.Itoa(response.StatusCode) + " Body: " + string(body)) + log.Println(theError.Error()) + return theError + } + + err = json.Unmarshal(body, &domains) + if err != nil { + log.Println(err.Error()) + return errors.New("Couldn't store response body from host " + host + " into domains list: " + err.Error()) + } + + err = writeDomains() + if err != nil { + log.Println(err.Error()) + return err + } + + return nil +} + +func renew() error { + //BUILD/SET GETSSL ENVIRONMENT VARIABLES THEN EXECUTE GETSSL + + //domain list + var domainlist string + if len(domains) > 0 { + domainlist = domains[0] + } + for i, d := range domains { + if i == 0 { //we already added first one + continue + } + domainlist = domainlist + "," + d + } + err := os.Setenv("SANS", domainlist) + if err != nil { + return errors.New("RENEW: error setting SANS domains list environment variable: " + err.Error()) + } + fmt.Println(domainlist) + + //ACL string + aclstring := leapiconf.LetsEncryptValidationPath + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + aclstring += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.LetsEncryptValidationPath + } + err = os.Setenv("ACL", aclstring) + if err != nil { + return errors.New("RENEW: error setting ACL environment variable: " + err.Error()) + } + fmt.Println(aclstring) + + //Cert and key locations + domain_cert_location := leapiconf.TLSCertFile + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + domain_cert_location += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.TLSCertFile + } + 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()) + } + fmt.Println(domain_cert_location) + + domain_key_location := leapiconf.TLSKeyFile + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + domain_key_location += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.TLSKeyFile + } + 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 := leapiconf.TLSChainFile + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + domain_chain_location += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.TLSChainFile + } + 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 := leapiconf.TLSPEMFile + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + domain_pem_location += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.TLSPEMFile + } + err = os.Setenv("DOMAIN_PEM_LOCATION", domain_pem_location) + if err != nil { + return errors.New("RENEW: error setting DOMAIN_PEM_LOCATION environment variable: " + err.Error()) + } + + ca_cert_location := leapiconf.TLSCAFile + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + ca_cert_location += ";ssh:" + leapiconf.Username + "@" + server + ":" + leapiconf.TLSCAFile + } + err = os.Setenv("CA_CERT_LOCATION", ca_cert_location) + if err != nil { + return errors.New("RENEW: error setting CA_CERT_LOCATION environment variable: " + err.Error()) + } + + //reload command + reload_command := leapiconf.ReloadCommand + for _, server := range servers { + if server == leapiconf.Hostname { + continue + } + reload_command += "; ssh " + leapiconf.Username + "@" + server + " '" + leapiconf.ReloadCommand + "' " + } + err = os.Setenv("RELOAD_CMD", reload_command) + if err != nil { + return errors.New("RENEW: error setting RELOAD_COMMAND environment variable: " + err.Error()) + } + fmt.Println(reload_command) + + //RUN GETSSL + //run getssl on primary domain to renew + cmd := exec.Command(leapiconf.SrvDir+"/getssl", leapiconf.PrimaryDomain) + output, err := cmd.CombinedOutput() + if err != nil { + log.Println(string(output)) + return errors.New("RENEW: execution of getssl failed: " + err.Error()) + } + + log.Println("BEGIN GETSSL OUTPUT:") + log.Println(string(output)) + log.Println("END GETSSL OUTPUT:") + + return nil +} + +func uptime() UpOut { + uptime := fmt.Sprintf("%s", time.Since(startupTime)) + + out := UpOut{ + Up: true, + StartTime: startupTime, + Uptime: uptime, + } + + return out +} + +///////////////////////////////////////////// +///// API ROUTE FUNCTIONS +///////////////////////////////////////////// +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 apiRenew(c echo.Context) error { + err := renew() + if err != nil { + return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error())) + } + return c.JSON(okOut()) +} + +func apiListDomains(c echo.Context) error { + var out APIOutput + out.Status = http.StatusOK + out.Message = "domains list" + out.Data = domains + return c.JSON(out.Status, out) +} + +func apiPutDomain(c echo.Context) error { + domain := c.Param("domain") + + //check for dups + for _, d := range domains { + if d == domain { + return c.JSON(errorOut(http.StatusBadRequest, "Bad request: Domain already exists.")) + } + } + + //add domain to list + domains = append(domains, domain) + + //write list to disk + err := writeDomains() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error writing domains list to disk: "+err.Error())) + } + + //sync with other servers + err = syncAllServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error())) + } + + //renew cert + err = renew() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error())) + } + + return c.JSON(okOut()) +} + +func apiDeleteDomain(c echo.Context) error { + deleteDomain := c.Param("domain") + var newlist []string + for _, d := range domains { + if d != deleteDomain { + newlist = append(newlist, d) + } + } + + domains = newlist + + //write list to disk + err := writeDomains() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error writing domains list to disk: "+err.Error())) + } + + //sync with other servers + err = syncAllServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error())) + } + + //renew cert + err = renew() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error())) + } + + return c.JSON(okOut()) +} + +func apiListServers(c echo.Context) error { + var out APIOutput + out.Status = http.StatusOK + out.Message = "servers list" + out.Data = servers + return c.JSON(out.Status, out) +} + +func apiPutServer(c echo.Context) error { + server := c.Param("server") + + //check for dups + for _, s := range servers { + if s == server { + return c.JSON(errorOut(http.StatusBadRequest, "Bad request: Server already exists.")) + } + } + + //add servers to list + servers = append(servers, server) + + //write list to disk + err := writeServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error writing servers list to disk: "+err.Error())) + } + + //sync with other servers + err = syncAllServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error())) + } + + //renew cert + err = renew() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error())) + } + + return c.JSON(okOut()) +} + +func apiDeleteServer(c echo.Context) error { + deleteServer := c.Param("server") + + var newlist []string + for _, s := range servers { + if s != deleteServer { + newlist = append(newlist, s) + } + } + servers = newlist + + //write list to disk + err := writeServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error writing servers list to disk: "+err.Error())) + } + + //sync with other servers + err = syncAllServers() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing to other servers: "+err.Error())) + } + + //renew cert + err = renew() + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error renewing: "+err.Error())) + } + + return c.JSON(okOut()) +} + +func apiSync(c echo.Context) error { + host := c.Param("host") + + err := syncServersFromHost(host) + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing servers from host: "+host+". "+err.Error())) + } + + err = syncDomainsFromHost(host) + if err != nil { + log.Println(err.Error()) + return c.JSON(errorOut(http.StatusInternalServerError, "Error syncing domains from host: "+host+". "+err.Error())) + } + + return c.JSON(okOut()) +}