Browse Source

auto update script executed

Toby Chui 1 year ago
parent
commit
ceaa32a395
9 changed files with 359 additions and 23 deletions
  1. 35 1
      acme.go
  2. 7 3
      api.go
  3. 2 0
      main.go
  4. 21 4
      mod/acme/acme.go
  5. 135 0
      mod/acme/autorenew.go
  6. 45 0
      mod/acme/ca.go
  7. 1 1
      mod/acme/utils.go
  8. 5 0
      start.go
  9. 108 14
      web/snippet/acme.html

+ 35 - 1
acme.go

@@ -12,6 +12,7 @@ import (
 
 	"imuslab.com/zoraxy/mod/acme"
 	"imuslab.com/zoraxy/mod/dynamicproxy"
+	"imuslab.com/zoraxy/mod/utils"
 )
 
 /*
@@ -37,7 +38,7 @@ func initACME() *acme.ACMEHandler {
 		port = getRandomPort(30000)
 	}
 
-	return acme.NewACME("admin@alanyeung.co", "https://acme-staging-v02.api.letsencrypt.org/directory", strconv.Itoa(port))
+	return acme.NewACME("admin@imuslab.com", "https://acme-staging-v02.api.letsencrypt.org/directory", strconv.Itoa(port))
 }
 
 // create the special routing rule for ACME
@@ -75,3 +76,36 @@ func acmeRegisterSpecialRoutingRule() {
 		log.Println("[Err] " + err.Error())
 	}
 }
+
+// This function check if the renew setup is satisfied. If not, toggle them automatically
+func AcmeCheckAndHandleRenewCertificate(w http.ResponseWriter, r *http.Request) {
+	isForceHttpsRedirectEnabledOriginally := false
+	if dynamicProxyRouter.Option.Port == 443 {
+		//Enable port 80 to 443 redirect
+		if !dynamicProxyRouter.Option.ForceHttpsRedirect {
+			log.Println("Temporary enabling HTTP to HTTPS redirect for ACME certificate renew requests")
+			dynamicProxyRouter.UpdateHttpToHttpsRedirectSetting(true)
+		} else {
+			//Set this to true, so after renew, do not turn it off
+			isForceHttpsRedirectEnabledOriginally = true
+		}
+
+	} else if dynamicProxyRouter.Option.Port == 80 {
+		//Go ahead
+
+	} else {
+		//This port do not support ACME
+		utils.SendErrorResponse(w, "ACME renew only support web server listening on port 80 (http) or 443 (https)")
+	}
+
+	// Pass over to the acmeHandler to deal with the communication
+	acmeHandler.HandleRenewCertificate(w, r)
+
+	if dynamicProxyRouter.Option.Port == 443 {
+		if !isForceHttpsRedirectEnabledOriginally {
+			//Default is off. Turn the redirection off
+			log.Println("Restoring HTTP to HTTPS redirect settings")
+			dynamicProxyRouter.UpdateHttpToHttpsRedirectSetting(false)
+		}
+	}
+}

+ 7 - 3
api.go

@@ -152,9 +152,13 @@ func initAPIs() {
 	//Others
 	http.HandleFunc("/api/info/x", HandleZoraxyInfo)
 
-	//ACME
-	http.HandleFunc("/api/acme/listExpiredDomains", acmeHandler.HandleGetExpiredDomains)
-	http.HandleFunc("/api/acme/obtainCert", acmeHandler.HandleRenewCertificate)
+	//ACME & Auto Renewer
+	authRouter.HandleFunc("/api/acme/listExpiredDomains", acmeHandler.HandleGetExpiredDomains)
+	authRouter.HandleFunc("/api/acme/obtainCert", AcmeCheckAndHandleRenewCertificate)
+	authRouter.HandleFunc("/api/acme/autoRenew/email", acmeAutoRenewer.HandleACMEEmail)
+	authRouter.HandleFunc("/api/acme/autoRenew/setDomains", acmeAutoRenewer.HandleSetAutoRenewDomains)
+	authRouter.HandleFunc("/api/acme/autoRenew/listDomains", acmeAutoRenewer.HandleLoadAutoRenewDomains)
+	authRouter.HandleFunc("/api/acme/autoRenew/renewNow", acmeAutoRenewer.HandleRenewNow)
 
 	//If you got APIs to add, append them here
 }

+ 2 - 0
main.go

@@ -38,6 +38,7 @@ var showver = flag.Bool("version", false, "Show version of this server")
 var allowSshLoopback = flag.Bool("sshlb", false, "Allow loopback web ssh connection (DANGER)")
 var ztAuthToken = flag.String("ztauth", "", "ZeroTier authtoken for the local node")
 var ztAPIPort = flag.Int("ztport", 9993, "ZeroTier controller API port")
+var acmeAutoRenewInterval = flag.Int("autorenew", 86400, "ACME auto TLS/SSL certificate renew check interval")
 var (
 	name        = "Zoraxy"
 	version     = "2.6.5"
@@ -69,6 +70,7 @@ var (
 	webSshManager      *sshprox.Manager        //Web SSH connection service
 	tcpProxyManager    *tcpprox.Manager        //TCP Proxy Manager
 	acmeHandler        *acme.ACMEHandler       //Handler for ACME Certificate renew
+	acmeAutoRenewer    *acme.AutoRenewer       //Handler for ACME auto renew ticking
 
 	//Helper modules
 	EmailSender    *email.Sender        //Email sender that handle email sending

+ 21 - 4
mod/acme/acme.go

@@ -66,7 +66,7 @@ func NewACME(email string, acmeServer string, port string) *ACMEHandler {
 }
 
 // ObtainCert obtains a certificate for the specified domains.
-func (a *ACMEHandler) ObtainCert(domains []string, certificateName string) (bool, error) {
+func (a *ACMEHandler) ObtainCert(domains []string, certificateName string, ca string) (bool, error) {
 	log.Println("Obtaining certificate...")
 
 	// generate private key
@@ -87,6 +87,16 @@ func (a *ACMEHandler) ObtainCert(domains []string, certificateName string) (bool
 
 	// setup who is the issuer and the key type
 	config.CADirURL = a.acmeServer
+
+	//Overwrite the CADir URL if set
+	if ca != "" {
+		caLinkOverwrite, err := loadCAApiServerFromName(ca)
+		if err == nil {
+			config.CADirURL = caLinkOverwrite
+			log.Println("[INFO] Using " + caLinkOverwrite + " for CA Directory URL")
+		}
+	}
+
 	config.Certificate.KeyType = certcrypto.RSA2048
 
 	client, err := lego.NewClient(config)
@@ -221,18 +231,25 @@ func (a *ACMEHandler) HandleGetExpiredDomains(w http.ResponseWriter, r *http.Req
 // It retrieves the domains and filename parameters from the request, calls the ObtainCert method
 // to renew the certificate, and sends a JSON response indicating the result of the renewal process.
 func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) {
-	domainPara, err := utils.GetPara(r, "domains")
+	domainPara, err := utils.PostPara(r, "domains")
 	if err != nil {
 		utils.SendErrorResponse(w, jsonEscape(err.Error()))
 		return
 	}
-	filename, err := utils.GetPara(r, "filename")
+	filename, err := utils.PostPara(r, "filename")
 	if err != nil {
 		utils.SendErrorResponse(w, jsonEscape(err.Error()))
 		return
 	}
+
+	ca, err := utils.PostPara(r, "ca")
+	if err != nil {
+		log.Println("CA not set. Using default (Let's Encrypt)")
+		ca = "Let's Encrypt"
+	}
+
 	domains := strings.Split(domainPara, ",")
-	result, err := a.ObtainCert(domains, filename)
+	result, err := a.ObtainCert(domains, filename, ca)
 	if err != nil {
 		utils.SendErrorResponse(w, jsonEscape(err.Error()))
 		return

+ 135 - 0
mod/acme/autorenew.go

@@ -0,0 +1,135 @@
+package acme
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"time"
+
+	"imuslab.com/zoraxy/mod/utils"
+)
+
+/*
+	autorenew.go
+
+	This script handle auto renew
+*/
+
+type AutoRenewConfig struct {
+	Email          string //Email for acme
+	AutomaticRenew bool   //Automatic renew is enabled
+	RenewAll       bool   //Renew all or selective renew with the slice below
+	DomainsToRenew []string
+}
+
+type AutoRenewer struct {
+	ConfigFilePath string
+	CertFolder     string
+	RenewerConfig  *AutoRenewConfig
+	TickerstopChan chan bool
+}
+
+// Create an auto renew agent, require config filepath and auto scan & renew interval (seconds)
+// Set renew check interval to 0 for auto (1 day)
+func NewAutoRenewer(config string, certFolder string, renewCheckInterval int64) (*AutoRenewer, error) {
+	if renewCheckInterval == 0 {
+		renewCheckInterval = 86400 //1 day
+	}
+
+	ticker := time.NewTicker(time.Duration(renewCheckInterval) * time.Second)
+	done := make(chan bool)
+
+	//Load the config file. If not found, create one
+	if !utils.FileExists(config) {
+		//Create one
+		os.MkdirAll(filepath.Dir(config), 0775)
+		newConfig := AutoRenewConfig{
+			RenewAll:       true,
+			DomainsToRenew: []string{},
+		}
+		js, _ := json.MarshalIndent(newConfig, "", " ")
+		err := os.WriteFile(config, js, 0775)
+		if err != nil {
+			return nil, errors.New("Failed to create acme auto renewer config: " + err.Error())
+		}
+	}
+
+	renewerConfig := AutoRenewConfig{}
+	content, err := os.ReadFile(config)
+	if err != nil {
+		return nil, errors.New("Failed to open acme auto renewer config: " + err.Error())
+	}
+
+	err = json.Unmarshal(content, &renewerConfig)
+	if err != nil {
+		return nil, errors.New("Malformed acme config file: " + err.Error())
+	}
+
+	//Create an Auto renew object
+	thisRenewer := AutoRenewer{
+		ConfigFilePath: config,
+		CertFolder:     certFolder,
+		RenewerConfig:  &renewerConfig,
+		TickerstopChan: done,
+	}
+
+	//Check and renew certificate on startup
+	thisRenewer.CheckAndRenewCertificates()
+
+	//Start the ticker to check and renew every x seconds
+	go func() {
+		for {
+			select {
+			case <-done:
+				return
+			case <-ticker.C:
+				log.Println("Check and renew certificates in progress")
+				thisRenewer.CheckAndRenewCertificates()
+			}
+		}
+	}()
+
+	return &thisRenewer, nil
+}
+
+func (a *AutoRenewer) HandleSetAutoRenewDomains(w http.ResponseWriter, r *http.Request) {
+
+}
+
+func (a *AutoRenewer) HandleLoadAutoRenewDomains(w http.ResponseWriter, r *http.Request) {
+
+}
+
+func (a *AutoRenewer) HandleRenewNow(w http.ResponseWriter, r *http.Request) {
+
+}
+
+func (a *AutoRenewer) HandleACMEEmail(w http.ResponseWriter, r *http.Request) {
+	/*
+		email, err := utils.PostPara(r, "set")
+		if err != nil {
+			currentEmail := ""
+		} else {
+
+		}
+	*/
+}
+
+// Check and renew certificates. This check all the certificates in the
+// certificate folder and return a list of certs that is renewed in this call
+// Return string array with length 0 when no cert is expired
+func (a *AutoRenewer) CheckAndRenewCertificates() ([]string, error) {
+	certFolder := a.CertFolder
+	files, err := os.ReadDir(certFolder)
+	if err != nil {
+		log.Println("Unable to renew certificates: " + err.Error())
+		return []string{}, err
+	}
+
+	fmt.Println("[ACME DEBUG] Cert found: ", files)
+	return []string{}, nil
+}

+ 45 - 0
mod/acme/ca.go

@@ -0,0 +1,45 @@
+package acme
+
+/*
+	CA.go
+
+	This script load CA defination from embedded ca.json
+*/
+import (
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"log"
+)
+
+// CA Defination, load from embeded json when startup
+type CaDef struct {
+	Production map[string]string
+	Test       map[string]string
+}
+
+//go:embed ca.json
+var caJson []byte
+
+var caDef CaDef = CaDef{}
+
+func init() {
+	runtimeCaDef := CaDef{}
+	err := json.Unmarshal(caJson, &runtimeCaDef)
+	if err != nil {
+		log.Println("[ERR] Unable to unmarshal CA def from embedded file. You sure your ca.json is valid?")
+		return
+	}
+
+	caDef = runtimeCaDef
+
+}
+
+// Get the CA ACME server endpoint and error if not found
+func loadCAApiServerFromName(caName string) (string, error) {
+	val, ok := caDef.Production[caName]
+	if !ok {
+		return "", errors.New("This CA is not supported")
+	}
+	return val, nil
+}

+ 1 - 1
mod/acme/utils.go

@@ -34,7 +34,7 @@ func ExtractIssuerNameFromPEM(pemFilePath string) (string, error) {
 	return issuer, nil
 }
 
-// Check if a cert is expired
+// Check if a cert is expired by public key
 func CertIsExpired(certBtyes []byte) bool {
 	block, _ := pem.Decode(certBtyes)
 	if block != nil {

+ 5 - 0
start.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 	"time"
 
+	"imuslab.com/zoraxy/mod/acme"
 	"imuslab.com/zoraxy/mod/auth"
 	"imuslab.com/zoraxy/mod/database"
 	"imuslab.com/zoraxy/mod/dynamicproxy/redirection"
@@ -195,6 +196,10 @@ func startupSequence() {
 		Obtaining certificates from ACME Server
 	*/
 	acmeHandler = initACME()
+	acmeAutoRenewer, err = acme.NewAutoRenewer("./rules/acme_conf.json", "./certs/", int64(*acmeAutoRenewInterval))
+	if err != nil {
+		log.Fatal(err)
+	}
 }
 
 // This sequence start after everything is initialized

+ 108 - 14
web/snippet/acme.html

@@ -9,7 +9,7 @@
         .disabled.table{
           opacity: 0.5;
           pointer-events: none;
-          user-select: none;
+          
         }
 
         .expiredDomain{
@@ -32,9 +32,18 @@
     </div>
     <div class="ui basic segment">
       <div class="ui toggle checkbox">
-        <input type="checkbox" id="enableCertAutoRenew" checked>
+        <input type="checkbox" id="enableCertAutoRenew">
         <label>Enable Certificate Auto Renew</label>
       </div>
+      <br>
+      <h3>ACME Email</h3>
+      <p>Email is generally required for renewing via ACME. Zoraxy do not support no-email renew due to security reasons.</p>
+      <div class="ui fluid action input">
+        <input id="caRegisterEmail" type="text" placeholder="[email protected]">
+        <button class="ui icon basic button">
+            <i class="blue save icon"></i>
+        </button>
+      </div>
     </div>
     <div class="ui yellow message">
       Certificate Renew only works on the certification authority (CA) supported by Zoraxy. Check Zoraxy wiki for more information on supported list of CAs.
@@ -50,7 +59,8 @@
               <div class="ui toggle checkbox">
                 <input type="checkbox" id="renewAllSupported" onchange="setAutoRenewIfCASupportMode(this.checked);" checked>
                 <label>Auto renew if CA is supported</label>
-              </div>
+              </div><br>
+              <button class="ui basic right floated button" style="margin-top: -2em;"><i class="yellow refresh icon"></i> Renew Now</button>
               <div class="ui horizontal divider"> OR </div>
               <p>Select the certificates to automatic renew in the list below</p>
               <table id="domainCertFileTable" class="ui very compact unstackable basic disabled table">
@@ -63,8 +73,9 @@
                 </thead>
                 <tbody id="domainTableBody"></tbody>
               </table>
+              <small><i class="ui red info circle icon"></i> Domain in red are expired</small><br>
               <button class="ui basic right floated button"><i class="blue save icon"></i> Save Changes</button>
-              <button class="ui basic right floated button"><i class="yellow refresh icon"></i> Renew All</button>
+              <button class="ui basic right floated button"><i class="yellow refresh icon"></i> Renew Selected</button>
               <br><br>
           </div>
       </div>
@@ -74,17 +85,21 @@
   <p>Pick a certificate below to force renew</p>
   <div class="ui form">
     <div class="field">
-      <label>Domains</label>
-      <input id="domainsInput" type="text" placeholder="example.com">
-      <small>If you have more than one domain in a single certificate, enter the domains separated by commas (e.g. test.example.com,example.com)</small>
+      <label>Domain(s)</label>
+      <input id="domainsInput" type="text" placeholder="example.com" onkeyup="checkIfInputDomainIsMultiple();">
+      <small>If you have more than one domain in a single certificate, enter the domains separated by commas (e.g. s1.dev.example.com,s2.dev.example.com)</small>
     </div>
-    <div class="field">
-      <label>Filename</label>
-      <input id="filenameInput" type="text" placeholder="Enter filename">
+    <div class="field multiDomainOnly" style="display:none;">
+      <label>Matching Rule</label>
+      <input id="filenameInput" type="text" placeholder="Enter filename (no file extension)">
+      <small>Matching rule to let Zoraxy pick which certificate to use (Also be used as filename). Usually is the longest common suffix of the entered addresses. (e.g. dev.example.com)</small>
+    </div>
+    <div class="field multiDomainOnly" style="display:none;">
+      <button class="ui basic fluid button" onclick="autoDetectMatchingRules();">Auto Detect Matching Rule</button>
     </div>
     <div class="field">
       <label>Certificate Authority (CA)</label>
-      <div class="ui selection dropdown">
+      <div class="ui selection dropdown" id="ca">
         <input type="hidden" name="ca">
         <i class="dropdown icon"></i>
         <div class="default text">Let's Encrypt</div>
@@ -92,7 +107,7 @@
           <div class="item" data-value="Let's Encrypt">Let's Encrypt</div>
           <div class="item" data-value="Buypass">Buypass</div>
           <div class="item" data-value="ZeroSSL">ZeroSSL</div>
-          <div class="item" data-value="Google">Google</div>
+          <!-- <div class="item" data-value="Google">Google</div> -->
         </div>
       </div>
     </div>
@@ -193,13 +208,26 @@
     function obtainCertificate() {
       var domains = $("#domainsInput").val();
       var filename = $("#filenameInput").val();
-
+      if (filename.trim() == "" && !domains.includes(",")){
+        //Zoraxy filename are the matching name for domains.
+        //Use the same as domains
+        filename = domains;
+      }else if (filename != "" && !domains.includes(",")){
+        //Invalid settings. Force the filename to be same as domain
+        //if there are only 1 domain
+        filename = domains;
+      }else{
+        parent.msgbox("Filename cannot be empty for certs containing multiple domains.")
+        return;
+      }
+      var ca = $("#ca").dropdown("get value");
       $.ajax({
         url: "/api/acme/obtainCert",
         method: "GET",
         data: {
           domains: domains,
-          filename: filename
+          filename: filename,
+          ca: ca,
         },
         success: function(response) {
           if (response.error) {
@@ -210,6 +238,9 @@
             console.log("Certificate renewed successfully");
             // Show success message
             parent.msgbox("Certificate renewed successfully");
+            
+            // Renew the parent certificate list
+            parent.initManagedDomainCertificateList();
           }
         },
         error: function(error) {
@@ -218,6 +249,69 @@
       });
     }
 
+    function checkIfInputDomainIsMultiple(){
+      var inputDomains = $("#domainsInput").val();
+      if (inputDomains.includes(",")){
+        $(".multiDomainOnly").show();
+      }else{
+        $(".multiDomainOnly").hide();
+      }
+    }
+
+    function autoDetectMatchingRules(){
+      var domainsString = $("#domainsInput").val();
+      if (!domainsString.includes(",")){
+        return domainsString;
+      }
+
+      let domains = domainsString.split(",");
+
+      //Clean out any spacing between commas
+      for (var i = 0; i < domains.length; i++){
+        domains[i] = domains[i].trim();
+      }
+
+      function getLongestCommonSuffix(strings) {
+        if (strings.length === 0) {
+          return ''; // Return an empty string if the array is empty
+        }
+
+        var sortedStrings = strings.slice().sort(); // Create a sorted copy of the array
+
+        var firstString = sortedStrings[0];
+        var lastString = sortedStrings[sortedStrings.length - 1];
+
+        var suffix = '';
+        var minLength = Math.min(firstString.length, lastString.length);
+
+        for (var i = 0; i < minLength; i++) {
+          if (firstString[firstString.length - 1 - i] !== lastString[lastString.length - 1 - i]) {
+            break; // Stop iterating if characters don't match
+          }
+          suffix = firstString[firstString.length - 1 - i] + suffix;
+        }
+
+        return suffix;
+      }
+
+      let longestSuffix = getLongestCommonSuffix(domains);
+
+      //Check if the suffix is a valid domain
+      if (longestSuffix.substr(0,1) == "."){
+        //Trim off the first dot
+        longestSuffix = longestSuffix.substr(1);
+      }
+
+      if (!longestSuffix.includes(".")){
+        parent.msgbox("Auto Detect failed: Multiple Domains", false, 5000);
+        return;
+      }
+      $("#filenameInput").val(longestSuffix);
+    }
+
+
+    //Clear  up the input field when page load
+    $("#filenameInput").val("");
   </script>
 </body>
 </html>