From ebd3a1211390f6c792b752756f759c763146e0fe Mon Sep 17 00:00:00 2001 From: Ruel Tmeizeh - RuhNet Date: Wed, 19 Feb 2025 15:27:28 -0500 Subject: [PATCH] Wildcard certs and Kazoo - wildcard cert functionality - Kazoo interaction over AMQP - works with Kazoo to create/delete DNS records required for DNS challenge - moved some things around (utility functions mostly) - updated README with build/install instructions --- README.md | 27 ++++- actions.go | 185 ++++++++++++++++++++++++++++++---- amqp.go | 209 +++++++++++++++++++++++++++++++++++++++ api.go | 187 ++++++++++++++++++++++++++++++++--- go.mod | 1 + go.sum | 35 +++++++ leapi_config.json.sample | 46 +++++---- main.go | 83 +++++----------- util.go | 53 ++++++++++ 9 files changed, 713 insertions(+), 113 deletions(-) create mode 100644 amqp.go create mode 100644 util.go diff --git a/README.md b/README.md index cd6a51d..e48f8c5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -46,7 +49,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 +67,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 +``` diff --git a/actions.go b/actions.go index e92f7b1..1aefcbb 100644 --- a/actions.go +++ b/actions.go @@ -139,7 +139,7 @@ func sendFileToServer(filePath, server, cert_idx string) error { } func renew(cert_idx int) error { - log.Println("Renew operation initiated...") + log.Println("Renew operation initiated for certgroup [" + strconv.Itoa(cert_idx) + "]...") //BUILD/SET GETSSL ENVIRONMENT VARIABLES THEN EXECUTE GETSSL //domain list @@ -240,19 +240,21 @@ func renew(cert_idx int) error { return errors.New("RENEW: error setting DOMAIN_CHAIN_LOCATION environment variable: " + err.Error()) } - domain_pem_location := appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" + //domain_pem_location := appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" + domain_fullpem_location := appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".crt" if appconf.SyncType == "ssh" { for _, server := range servers { if server == appconf.Hostname { continue } //domain_pem_location += ";davs:leapi:" + appconf.SecretKey + ":" + server + ":" + syncPort + ":/api/file/upload/pem" - domain_pem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" + //domain_fullpem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".pem" + domain_fullpem_location += ";ssh:" + appconf.Username + "@" + server + ":" + appconf.TLSPEMPath + fmt.Sprintf("%02d", cert_idx) + ".crt" } } else { //file sync type is HTTPS - domain_pem_location += ";davs:" + appconf.Username + ":" + appconf.SecretKey + ":" + appconf.Hostname + ":" + appconf.HTTPS_ServerPort + ":/api/file/sync/pem/" + fmt.Sprintf("%02d", cert_idx) + 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()) } @@ -295,9 +297,40 @@ func renew(cert_idx int) error { configFile += "CA_CERT_LOCATION=\"" + appconf.TLSCAPath + "\"\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+"/"+cg.PrimaryDomain+"/getssl.cfg", []byte(configFile), 0644) @@ -314,29 +347,54 @@ func renew(cert_idx int) 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, cg.PrimaryDomain) - } else { - cmd = exec.Command(appconf.SrvDir+"/getssl", "-w", appconf.SrvDir, cg.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 } @@ -366,3 +424,92 @@ func reload() error { 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 +} diff --git a/amqp.go b/amqp.go new file mode 100644 index 0000000..5d86609 --- /dev/null +++ b/amqp.go @@ -0,0 +1,209 @@ +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.SIGUSR1, 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") + } + } +} diff --git a/api.go b/api.go index f108f50..4bd8e03 100644 --- a/api.go +++ b/api.go @@ -9,23 +9,50 @@ import ( "net/http" "os" "path" + "strings" + "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 uptimeCheck(c echo.Context) error { - if c.Request().Method == http.MethodHead { - return c.NoContent(http.StatusOK) +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.String(http.StatusOK, "{\"up\":true}") - return c.JSON(http.StatusOK, uptime()) + return c.JSON(out.Status, out) } func apiRenew(c echo.Context) error { @@ -46,6 +73,44 @@ func apiReload(c echo.Context) error { return c.JSON(okOut()) } +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") @@ -137,6 +202,7 @@ func apiListDomains(c echo.Context) error { var out APIOutput out.Status = http.StatusOK out.Message = "domains list" + 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) } @@ -144,7 +210,26 @@ func apiListDomains(c echo.Context) error { } 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 _, cg := range certgroups { @@ -158,21 +243,46 @@ func apiPutDomain(c echo.Context) error { } } + wildcard := strings.HasPrefix(domain, "*.") //is wildcard domain + var certgroup_slot int //add domain to list - for n, cg := range certgroups { - if len(cg.Domains) < (appconf.MaxDomainsPerCert - 1) { //can't have more than 100 names on a single cert + 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 with appended version + 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) } @@ -195,7 +305,18 @@ func apiPutDomain(c echo.Context) error { 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()) @@ -203,10 +324,21 @@ func apiPutDomain(c echo.Context) error { func apiDeleteDomain(c echo.Context) error { deleteDomain := c.Param("domain") - var newlist []string 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) + } + } + for _, d := range cg.Domains { if d == deleteDomain { certgroup_slot = n //save the slot this deleted domain is in @@ -246,6 +378,7 @@ func apiListServers(c echo.Context) error { var out APIOutput out.Status = http.StatusOK out.Message = "servers list" + 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) } @@ -347,3 +480,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 +} diff --git a/go.mod b/go.mod index 603268c..5647ec8 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 2f7b177..5728092 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/leapi_config.json.sample b/leapi_config.json.sample index a4c8d87..b47d79b 100644 --- a/leapi_config.json.sample +++ b/leapi_config.json.sample @@ -1,29 +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 are not using a separate web server or proxy. "-" will assume port 80. - "https_server_enable":false, //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/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/privkey", - "tls_chain_path_prefix":"/etc/ssl/chain", - "tls_pem_path_prefix":"/etc/ssl/domain", - "tls_ca_path_prefix":"/etc/ssl/ca", - "max_domains_per_cert":100, //100 max - "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":false, //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" } diff --git a/main.go b/main.go index 13eed7d..15a8f0f 100644 --- a/main.go +++ b/main.go @@ -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.2.0" +const appname string = "leapi" +const version string = "1.3.2" const serverVersion string = "RuhNet LE API v" + version const apiVersion int = 1 const website string = "https://ruhnet.co" @@ -41,9 +41,9 @@ var syncScheme string = "http://" var syncPort string const banner = ` - ____ __ _ _ __ - / ___\ __ __/ /_ / \ / /__ __/ /_ - / /_/ // /_/ / _ \/ / \/ //__\_ __/ +--⟶____ __ _ _ __ +-⟶/ ___\ __ __/ /_ / \ / /__ __/ /_ +⟶/ /_/ // /_/ / _ \/ / \/ //__\_ __/ /_/ \_\\ ___/_/ /_/_/ \__/ \__,/_/ %s _____________________________________________________ ` @@ -64,7 +64,7 @@ type LEAPIConfig struct { 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_pem_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"` @@ -75,26 +75,18 @@ 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 CertGroup struct { - //CertPrefix string `json:"cert_prefix"` PrimaryDomain string `json:"primary_domain"` + Wildcard bool `json:"wildcard"` Domains []string `json:"domains"` } -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,omitempty"` -} - type keypairReloader struct { certMu sync.RWMutex cert *tls.Certificate @@ -161,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" @@ -205,6 +197,7 @@ func main() { syncPort = appconf.HTTP_ServerPort if appconf.SyncType == "https" { syncPort = appconf.HTTPS_ServerPort + syncScheme = "https://" } if appconf.LetsEncryptValidationPath == "-" || appconf.LetsEncryptValidationPath == "" { @@ -265,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) @@ -287,6 +283,9 @@ func main() { api.OPTIONS("/reload", apiReload) api.POST("/reload", apiReload) + 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) @@ -297,6 +296,12 @@ func main() { apiFile.OPTIONS("/sync/:fileType/:cert_idx", apiUploadSync) apiFile.PUT("/sync/:fileType/:cert_idx", apiUploadSync) + if appconf.KazooAMQP { + ///////////////////////////////////////////// + // AMQP System: + go amqp() + } + ///////////////////////////////////////////// // HTTP SERVERS CONFIG: @@ -453,41 +458,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 -} diff --git a/util.go b/util.go new file mode 100644 index 0000000..c0c589d --- /dev/null +++ b/util.go @@ -0,0 +1,53 @@ +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 +}