Alan Yeung 1 year ago
parent
commit
fdc931a305
5 changed files with 277 additions and 42 deletions
  1. 38 6
      acme.go
  2. 4 0
      api.go
  3. 105 35
      mod/acme/acme.go
  4. 1 1
      start.go
  5. 129 0
      web/acme.html

+ 38 - 6
acme.go

@@ -4,8 +4,12 @@ import (
 	"fmt"
 	"io/ioutil"
 	"log"
+	"math/rand"
+	"net"
 	"net/http"
 	"regexp"
+	"strconv"
+	"time"
 
 	"imuslab.com/zoraxy/mod/acme"
 	"imuslab.com/zoraxy/mod/dynamicproxy"
@@ -17,9 +21,40 @@ import (
 	This script handle special routing required for acme auto cert renew functions
 */
 
+var acmeHandler *acme.ACMEHandler
+
+// Helper function to generate a random port above a specified value
+func getRandomPort(minPort int) int {
+	return rand.Intn(65535-minPort) + minPort
+}
+
+// Helper function to check if a port is in use
+func isPortInUse(port int) bool {
+	address := fmt.Sprintf(":%d", port)
+	listener, err := net.Listen("tcp", address)
+	if err != nil {
+		return true // Port is in use
+	}
+	defer listener.Close()
+	return false // Port is not in use
+}
+
+func initACME() *acme.ACMEHandler {
+	log.Println("Start initializing ACME")
+	rand.Seed(time.Now().UnixNano())
+	// Generate a random port above 30000
+	port := getRandomPort(30000)
+
+	// Check if the port is already in use
+	for isPortInUse(port) {
+		port = getRandomPort(30000)
+	}
+
+	return acme.NewACME("[email protected]", "https://acme-staging-v02.api.letsencrypt.org/directory", strconv.Itoa(port))
+}
+
 func acmeRegisterSpecialRoutingRule() {
-	a := acme.NewACME("[email protected]", []string{"r5desktop.alanyeung.co"})
-	a.CheckCertificate()
+	log.Println("Assigned temporary port:" + acmeHandler.Getport())
 
 	err := dynamicProxyRouter.AddRoutingRules(&dynamicproxy.RoutingRule{
 		ID: "acme-autorenew",
@@ -29,7 +64,7 @@ func acmeRegisterSpecialRoutingRule() {
 		},
 		RoutingHandler: func(w http.ResponseWriter, r *http.Request) {
 
-			req, err := http.NewRequest(http.MethodGet, "http://localhost:5002"+r.RequestURI, nil)
+			req, err := http.NewRequest(http.MethodGet, "http://localhost:"+acmeHandler.Getport()+r.RequestURI, nil)
 			req.Host = r.Host
 			if err != nil {
 				fmt.Printf("client: could not create request: %s\n", err)
@@ -51,7 +86,4 @@ func acmeRegisterSpecialRoutingRule() {
 	if err != nil {
 		log.Println("[Err] " + err.Error())
 	}
-
-	a.ObtainCert()
-
 }

+ 4 - 0
api.go

@@ -146,6 +146,10 @@ func initAPIs() {
 	//Others
 	http.HandleFunc("/api/info/x", HandleZoraxyInfo)
 
+	//ACME
+	http.HandleFunc("/api/acme/listExpiredDomains", acmeHandler.HandleGetExpiredDomains)
+	http.HandleFunc("/api/acme/obtainCert", acmeHandler.HandleRenewCertificate)
+
 	//If you got APIs to add, append them here
 }
 

+ 105 - 35
mod/acme/acme.go

@@ -6,11 +6,15 @@ import (
 	"crypto/elliptic"
 	"crypto/rand"
 	"crypto/x509"
+	"encoding/json"
 	"encoding/pem"
 	"io/ioutil"
 	"log"
+	"net/http"
 	"os"
 	"path/filepath"
+	"strconv"
+	"strings"
 	"time"
 
 	"github.com/go-acme/lego/v4/certcrypto"
@@ -18,117 +22,118 @@ import (
 	"github.com/go-acme/lego/v4/challenge/http01"
 	"github.com/go-acme/lego/v4/lego"
 	"github.com/go-acme/lego/v4/registration"
+	"imuslab.com/zoraxy/mod/utils"
 )
 
 // You'll need a user or account type that implements acme.User
-type MyUser struct {
+type ACMEUser struct {
 	Email        string
 	Registration *registration.Resource
 	key          crypto.PrivateKey
 }
 
-func (u *MyUser) GetEmail() string {
+func (u *ACMEUser) GetEmail() string {
 	return u.Email
 }
-func (u MyUser) GetRegistration() *registration.Resource {
+func (u ACMEUser) GetRegistration() *registration.Resource {
 	return u.Registration
 }
-func (u *MyUser) GetPrivateKey() crypto.PrivateKey {
+func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey {
 	return u.key
 }
 
 type ACMEHandler struct {
-	email   string
-	domains []string
+	email      string
+	acmeServer string
+	port       string
 }
 
-func NewACME(email string, domains []string) *ACMEHandler {
+func NewACME(email string, acmeServer string, port string) *ACMEHandler {
 
 	return &ACMEHandler{
-		email:   email,
-		domains: domains,
+		email:      email,
+		acmeServer: acmeServer,
+		port:       port,
 	}
 }
 
-func (a *ACMEHandler) ObtainCert() {
+func (a *ACMEHandler) ObtainCert(domains []string, certificateName string) (bool, error) {
 	log.Println("Obtaining certificate...")
 
 	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
 	if err != nil {
-		log.Fatal(err)
+		log.Println(err)
+		return false, err
 	}
 
-	adminUser := MyUser{
+	log.Println(a.acmeServer)
+	adminUser := ACMEUser{
 		Email: a.email,
 		key:   privateKey,
 	}
 
 	config := lego.NewConfig(&adminUser)
 
-	config.CADirURL = "https://acme-staging-v02.api.letsencrypt.org/directory"
+	config.CADirURL = a.acmeServer
 	config.Certificate.KeyType = certcrypto.RSA2048
 
 	client, err := lego.NewClient(config)
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 
-	err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "5002"))
+	err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", a.port))
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 
 	// New users will need to register
 	reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 	adminUser.Registration = reg
 
 	request := certificate.ObtainRequest{
-		Domains: a.domains,
+		Domains: domains,
 		Bundle:  true,
 	}
 	certificates, err := client.Certificate.Obtain(request)
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 
 	// Each certificate comes back with the cert bytes, the bytes of the client's
 	// private key, and a certificate URL. SAVE THESE TO DISK.
-	certificateName := ""
-	if len(a.domains) == 1 {
-		certificateName = certificates.Domain
-	} else {
-		certificateName = "default"
-	}
 	err = ioutil.WriteFile("./certs/"+certificateName+".crt", certificates.Certificate, 0777)
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 	err = ioutil.WriteFile("./certs/"+certificateName+".key", certificates.PrivateKey, 0777)
 
 	if err != nil {
 		log.Println(err)
-		return
+		return false, err
 	}
 
-	// ... all done.
+	return true, nil
 }
 
-// Return a list of domains where the certificates covers
-func (a *ACMEHandler) CheckCertificate() {
+// Return a list of domains that is in expired certificates
+func (a *ACMEHandler) CheckCertificate() []string {
 
 	filenames, err := os.ReadDir("./certs/")
 
+	expiredCerts := []string{}
+
 	if err != nil {
 		log.Println(err)
-		return
+		return []string{}
 	}
 
 	for _, filename := range filenames {
@@ -145,16 +150,81 @@ func (a *ACMEHandler) CheckCertificate() {
 				cert, err := x509.ParseCertificate(block.Bytes)
 				if err == nil {
 					elapsed := time.Since(cert.NotAfter)
-					approxMonths := -int(elapsed.Hours() / (24 * 30.44))
-					approxDays := -int(elapsed.Hours()/24) % 30
+					//approxMonths := -int(elapsed.Hours() / (24 * 30.44))
+					//approxDays := -int(elapsed.Hours()/24) % 30
 					if elapsed > 0 {
-						log.Println("Certificate", certFilepath, " expired")
+						//log.Println("Certificate", certFilepath, " expired")
+						for _, dnsName := range cert.DNSNames {
+							if !contains(expiredCerts, dnsName) {
+								expiredCerts = append(expiredCerts, dnsName)
+							}
+						}
+						if !contains(expiredCerts, cert.Subject.CommonName) {
+							expiredCerts = append(expiredCerts, cert.Subject.CommonName)
+						}
 					} else {
-						log.Println("Certificate", certFilepath, " will still vaild for the next ", approxMonths, "m", approxDays, "d")
+						//log.Println("Certificate", certFilepath, " will still vaild for the next ", approxMonths, "m", approxDays, "d")
 					}
 				}
 			}
 		}
 
 	}
+
+	return expiredCerts
+}
+
+func (a *ACMEHandler) Getport() string {
+	return a.port
+}
+
+func contains(slice []string, str string) bool {
+	for _, s := range slice {
+		if s == str {
+			return true
+		}
+	}
+	return false
+}
+
+func (a *ACMEHandler) HandleGetExpiredDomains(w http.ResponseWriter, r *http.Request) {
+	type ExpiredDomains struct {
+		Domain []string `json:"domain"`
+	}
+
+	info := ExpiredDomains{
+		Domain: a.CheckCertificate(),
+	}
+
+	js, _ := json.MarshalIndent(info, "", " ")
+	utils.SendJSONResponse(w, string(js))
+}
+
+func (a *ACMEHandler) HandleRenewCertificate(w http.ResponseWriter, r *http.Request) {
+	domainPara, err := utils.GetPara(r, "domains")
+	if err != nil {
+		utils.SendErrorResponse(w, jsonEscape(err.Error()))
+		return
+	}
+	filename, err := utils.GetPara(r, "filename")
+	if err != nil {
+		utils.SendErrorResponse(w, jsonEscape(err.Error()))
+		return
+	}
+	domains := strings.Split(domainPara, ",")
+	result, err := a.ObtainCert(domains, filename)
+	if err != nil {
+		utils.SendErrorResponse(w, jsonEscape(err.Error()))
+		return
+	}
+	utils.SendJSONResponse(w, strconv.FormatBool(result))
+}
+
+func jsonEscape(i string) string {
+	b, err := json.Marshal(i)
+	if err != nil {
+		panic(err)
+	}
+	s := string(b)
+	return s[1 : len(s)-1]
 }

+ 1 - 1
start.go

@@ -194,7 +194,7 @@ func startupSequence() {
 
 		Obtaining certificates from ACME Server
 	*/
-
+	acmeHandler = initACME()
 }
 
 // This sequence start after everything is initialized

+ 129 - 0
web/acme.html

@@ -0,0 +1,129 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
+  <title>ACME</title>
+</head>
+<body>
+
+  <div class="ui container">
+    <h1 class="ui header">Welcome to ACME</h1>
+
+    <div class="ui segment">
+      <p>This is an example of using ACME.</p>
+      <button id="fetchButton" class="ui primary button">Fetch Expired Domains</button>
+    </div>
+
+    <div id="domainTable" class="ui segment hidden">
+      <h2 class="ui header">Expired Domains</h2>
+      <table id="domainList" class="ui celled table">
+        <thead>
+          <tr>
+            <th>Domain</th>
+          </tr>
+        </thead>
+        <tbody></tbody>
+      </table>
+      <div class="ui segment">
+        <h2 class="ui header">Obtain Certificate</h2>
+        <div class="ui form">
+          <div class="field">
+            <label>Domains</label>
+            <input id="domainsInput" type="text" placeholder="Enter domains separated by commas (e.g. r5desktop.alanyeung.co,alanyeung.co)">
+          </div>
+          <div class="field">
+            <label>Filename</label>
+            <input id="filenameInput" type="text" placeholder="Enter filename">
+          </div>
+          <button id="obtainButton" class="ui button" type="submit">Obtain Certificate</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.js"></script>
+  <script>
+    $(document).ready(function() {
+      // Button click event handler
+      $("#fetchButton").click(function() {
+        fetchExpiredDomains();
+      });
+
+      // Fetch expired domains from API
+      function fetchExpiredDomains() {
+        $.ajax({
+          url: "/api/acme/listExpiredDomains",
+          method: "GET",
+          success: function(response) {
+            // Render domain table
+            renderDomainTable(response.domain);
+          },
+          error: function(error) {
+            console.log("Failed to fetch expired domains:", error);
+          }
+        });
+      }
+
+      // Render domain table with data
+      function renderDomainTable(domains) {
+        var tableBody = $("#domainList tbody");
+        tableBody.empty();
+
+        $.each(domains, function(index, domain) {
+          var row = $("<tr>").appendTo(tableBody);
+          $("<td>").text(domain).appendTo(row);
+        });
+
+        // Show the domain table
+        $("#domainTable").removeClass("hidden");
+      }
+
+      // Button click event handler for obtaining certificate
+      $("#obtainButton").click(function() {
+        obtainCertificate();
+      });
+
+      // Obtain certificate from API
+      function obtainCertificate() {
+        var domains = $("#domainsInput").val();
+        var filename = $("#filenameInput").val();
+
+        $.ajax({
+          url: "/api/acme/obtainCert",
+          method: "GET",
+          data: {
+            domains: domains,
+            filename: filename
+          },
+          success: function(response) {
+            if (response.error) {
+              console.log("Error:", response.error);
+              // Show error message
+              showMessage(response.error);
+            } else {
+              console.log("Certificate obtained successfully");
+              // Show success message
+              showMessage("Certificate obtained successfully");
+            }
+          },
+          error: function(error) {
+            console.log("Failed to obtain certificate:", error);
+          }
+        });
+      }
+
+      // Show message in a popup
+      function showMessage(message) {
+        $("<div>").addClass("ui message").text(message).appendTo("body").modal({
+          onHide: function() {
+            $(this).remove();
+          }
+        }).modal("show");
+      }
+    });
+  </script>
+</body>
+</html>