Optimized TLS list

- Optimized TLS certificate list
- Added common name auto fill for certificate upload tool
- Updated version number
- Removed unused code in helper
This commit is contained in:
Toby Chui
2025-10-13 21:09:29 +08:00
parent 9ed9d9ede4
commit 66572981b3
5 changed files with 195 additions and 37 deletions

View File

@@ -79,6 +79,8 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/cert/setPreferredCertificate", handleSetDomainPreferredCertificate)
//Certificate store functions
authRouter.HandleFunc("/api/cert/setDefault", tlsCertManager.SetCertAsDefault)
authRouter.HandleFunc("/api/cert/getCommonName", tlsCertManager.HandleGetCertCommonName)
authRouter.HandleFunc("/api/cert/upload", tlsCertManager.HandleCertUpload)
authRouter.HandleFunc("/api/cert/download", tlsCertManager.HandleCertDownload)
authRouter.HandleFunc("/api/cert/list", tlsCertManager.HandleListCertificate)

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.7"
SYSTEM_VERSION = "3.2.8"
DEVELOPMENT_BUILD = false
/* System Constants */

View File

@@ -75,6 +75,50 @@ func (m *Manager) HandleCertDownload(w http.ResponseWriter, r *http.Request) {
}
}
// Set the selected certificate as the default / fallback certificate
func (m *Manager) SetCertAsDefault(w http.ResponseWriter, r *http.Request) {
certname, err := utils.PostPara(r, "certname")
if err != nil {
utils.SendErrorResponse(w, "invalid certname given")
return
}
//Check if the previous default cert exists. If yes, get its hostname from cert contents
defaultPubKey := filepath.Join(m.CertStore, "default.key")
defaultPriKey := filepath.Join(m.CertStore, "default.pem")
if utils.FileExists(defaultPubKey) && utils.FileExists(defaultPriKey) {
//Move the existing default cert to its original name
certBytes, err := os.ReadFile(defaultPriKey)
if err == nil {
block, _ := pem.Decode(certBytes)
if block != nil {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
os.Rename(defaultPubKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "key")))
os.Rename(defaultPriKey, filepath.Join(m.CertStore, domainToFilename(cert.Subject.CommonName, "pem")))
}
}
}
}
//Check if the cert exists
certname = filepath.Base(certname) //prevent path escape
pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".key")
priKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem")
if utils.FileExists(pubKey) && utils.FileExists(priKey) {
os.Rename(pubKey, filepath.Join(m.CertStore, "default.key"))
os.Rename(priKey, filepath.Join(m.CertStore, "default.pem"))
utils.SendOK(w)
//Update cert list
m.UpdateLoadedCertList()
} else {
utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store")
return
}
}
// Handle upload of the certificate
func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) {
// check if request method is POST
@@ -124,6 +168,13 @@ func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) {
defer file.Close()
// create file in upload directory
// Read file contents for validation
fileBytes, err := io.ReadAll(file)
if err != nil {
http.Error(w, "Failed to read file", http.StatusBadRequest)
return
}
os.MkdirAll(m.CertStore, 0775)
f, err := os.Create(filepath.Join(m.CertStore, overWriteFilename))
if err != nil {
@@ -138,6 +189,11 @@ func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
_, err = f.Write(fileBytes)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
//Update cert list
m.UpdateLoadedCertList()
@@ -215,11 +271,13 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request)
showDate, _ := utils.GetBool(r, "date")
if showDate {
type CertInfo struct {
Domain string
Domain string // Domain name of the certificate
Filename string // Filename that stores the certificate
LastModifiedDate string
ExpireDate string
RemainingDays int
UseDNS bool
UseDNS bool // Whether this cert is obtained via DNS challenge
IsFallback bool // Whether this cert is the fallback/default cert
}
results := []*CertInfo{}
@@ -248,7 +306,7 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request)
if err == nil {
certExpireTime = cert.NotAfter.Format("2006-01-02 15:04:05")
duration := cert.NotAfter.Sub(time.Now())
duration := time.Until(cert.NotAfter)
// Convert the duration to days
expiredIn = int(duration.Hours() / 24)
@@ -262,12 +320,23 @@ func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request)
useDNSValidation = certInfo.UseDNS
}
certDomain := ""
block, _ := pem.Decode(certBtyes)
if block != nil {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
certDomain = cert.Subject.CommonName
}
}
thisCertInfo := CertInfo{
Domain: filename,
Domain: certDomain,
Filename: filename,
LastModifiedDate: modifiedTime,
ExpireDate: certExpireTime,
RemainingDays: expiredIn,
UseDNS: useDNSValidation,
IsFallback: (filename == "default"), //TODO: figure out a better implementation
}
results = append(results, &thisCertInfo)
@@ -350,3 +419,25 @@ func (m *Manager) HandleSelfSignCertGenerate(w http.ResponseWriter, r *http.Requ
}
utils.SendOK(w)
}
// Extract the common name from a PEM encoded certificate
func (m *Manager) HandleGetCertCommonName(w http.ResponseWriter, r *http.Request) {
certContents, err := utils.PostPara(r, "cert")
if err != nil {
utils.SendErrorResponse(w, "Certificate content not provided")
return
}
block, _ := pem.Decode([]byte(certContents))
if block == nil {
utils.SendErrorResponse(w, "Failed to decode PEM block")
return
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
utils.SendErrorResponse(w, "Failed to parse certificate: "+err.Error())
return
}
js, _ := json.Marshal(cert.Subject.CommonName)
utils.SendJSONResponse(w, string(js))
}

View File

@@ -29,21 +29,6 @@ func getCertPairs(certFiles []string) []string {
return result
}
// Get the cloest subdomain certificate from a list of domains
func matchClosestDomainCertificate(subdomain string, domains []string) string {
var matchingDomain string = ""
maxLength := 0
for _, domain := range domains {
if strings.HasSuffix(subdomain, "."+domain) && len(domain) > maxLength {
matchingDomain = domain
maxLength = len(domain)
}
}
return matchingDomain
}
// Convert a domain name to a filename format
func domainToFilename(domain string, ext string) string {
// Replace wildcard '*' with '_'
@@ -52,6 +37,10 @@ func domainToFilename(domain string, ext string) string {
domain = "_" + strings.TrimPrefix(domain, "*")
}
if strings.HasPrefix(".", ext) {
ext = strings.TrimPrefix(ext, ".")
}
// Add .pem extension
ext = strings.TrimPrefix(ext, ".") // Ensure ext does not start with a dot
return domain + "." + ext

View File

@@ -7,7 +7,12 @@
.valid.certdate{
color: #31c071;
}
#certifiedDomainList .ui.basic.button{
margin-top: 0.1em;
}
</style>
<script src="script/jsrsasign-all-min.js"></script>
<div class="standardContainer">
<div class="ui basic segment">
<h2>TLS / SSL Certificates</h2>
@@ -21,7 +26,7 @@
<div class="three fields">
<div class="field">
<label>Server Name (Domain)</label>
<input type="text" id="certdomain" placeholder="example.com / blog.example.com">
<input type="text" id="certdomain" placeholder="">
<small><i class="exclamation circle yellow icon"></i> Match the server name with your CN/DNS entry in certificate for faster resolve time</small>
</div>
<div class="field">
@@ -59,23 +64,25 @@
</div>
</div>
<p>Current list of loaded certificates</p>
<div tourstep="certTable">
<div style="width: 100%; overflow-x: auto; margin-bottom: 1em;">
<div tourstep="certTable" style="width: 100%; overflow-x: auto; padding-bottom: 1em;">
<div style="min-width: 960px; max-width: 100%; ">
<table class="ui unstackable basic celled table">
<thead>
<tr><th>Domain</th>
<th>Last Update</th>
<th>Expire At</th>
<th>DNS Challenge</th>
<th class="no-sort">Renew</th>
<th class="no-sort">Remove</th>
<tr>
<th>Domain</th>
<th>Filename</th>
<th>Last Update</th>
<th>Expire At</th>
<th>Fallback</th>
<th>DNS Challenge</th>
<th class="no-sort">Actions</th>
</tr></thead>
<tbody id="certifiedDomainList">
</tbody>
</table>
</div>
<br>
<button class="ui basic button" onclick="initManagedDomainCertificateList();"><i class="green refresh icon"></i> Refresh List</button>
</div>
<div class="ui divider"></div>
@@ -103,6 +110,8 @@
<button class="ui basic grey button" onclick="uploadPublicKey();"><i class="globe icon"></i> Public Key</button>
<button class="ui basic button" onclick="uploadPrivateKey();"><i class="grey lock icon"></i> Private Key</button>
</div>
<div class="ui yellow message"><i class="exclamation triangle icon"></i> We will be removing the fallback certificate section soon. <br>
Please use "<i class="ui blue home icon"></i>Set Fallback" button in the certificate list above to set the fallback certificate.</div>
</div>
<div class="ui divider"></div>
<div tourstep="acmeSettings">
@@ -150,6 +159,58 @@
$("#defaultCA").dropdown();
function getPossibleCommonNameFromSelectedCertificate(){
const fileInput = document.getElementById('pubkeySelector');
const file = fileInput.files[0];
if (!file) {
msgbox("No certificate file selected", false, 4000);
return;
}
const reader = new FileReader();
reader.onload = function(e) {
const certContent = e.target.result;
$.cjax({
url: '/api/cert/getCommonName',
method: 'POST',
data: { cert: certContent },
success: function(data) {
if (data.error !== undefined) {
//Ignore error
$("#certdomain").attr("placeholder", "");
} else if (data) {
if (typeof data === "string" && data.startsWith("*.")) {
data = data.substring(2);
}
$("#certdomain").attr("placeholder", data);
}
},
error: function(xhr) {
//Ignore error
}
});
};
reader.readAsText(file);
}
function setSelectedCertAsFallbackCertificate(certDomain){
$.cjax({
url: '/api/cert/setDefault',
method: 'POST',
data: { certname: certDomain },
success: function(data) {
if (data.error !== undefined) {
msgbox(data.error, false, 5000);
} else {
msgbox('Fallback certificate set successfully!');
initManagedDomainCertificateList();
initDefaultKeypairCheck();
}
},
error: function(xhr) {
msgbox('Failed to set fallback certificate', false, 5000);
}
});
}
//Renew certificate by button press
function renewCertificate(domain, dns, btn=undefined){
@@ -378,17 +439,22 @@
});
data.forEach(entry => {
let isExpired = entry.RemainingDays <= 0;
let entryDomainRenewKey = entry.Domain;
let entryDomainRenewKey = entry.Filename;
if (entryDomainRenewKey.includes("_.")){
entryDomainRenewKey = entryDomainRenewKey.replace("_.","*.");
}
$("#certifiedDomainList").append(`<tr>
<td><a style="cursor: pointer;" title="Download certificate" onclick="handleCertDownload('${entry.Domain}');">${entry.Domain}</a></td>
<td><a style="cursor: pointer;" title="Download certificate" onclick="handleCertDownload('${entry.Filename}');">${entry.Domain}</a></td>
<td>${entry.Filename}</td>
<td>${entry.LastModifiedDate}</td>
<td class="${isExpired?"expired":"valid"} certdate">${entry.ExpireDate} (${!isExpired?entry.RemainingDays+" days left":"Expired"})</td>
<td>${entry.IsFallback?"<i class='green check icon'></i>":""}</td>
<td><i class="${entry.UseDNS?"green check": "red times"} icon"></i></td>
<td><button title="Renew Certificate" class="ui mini basic icon button renewButton" onclick="renewCertificate('${entryDomainRenewKey}', '${entry.UseDNS}', this);"><i class="ui green refresh icon"></i></button></td>
<td><button title="Delete key-pair" class="ui mini basic red icon button" onclick="deleteCertificate('${entry.Domain}');"><i class="ui red trash icon"></i></button></td>
<td>
<button title="Set as Default / Fallback Certificate" class="ui mini basic button ${(entry.IsFallback?"disabled":"")} " onclick="setSelectedCertAsFallbackCertificate('${entry.Filename}');"><i class="ui blue home icon"></i> Set Fallback</button>
<button title="Renew Certificate" class="ui mini basic icon button renewButton" onclick="renewCertificate('${entryDomainRenewKey}', '${entry.UseDNS}', this);"><i class="ui green refresh icon"></i></button>
<button title="Delete key-pair" class="ui mini basic icon red button" onclick="deleteCertificate('${entry.Filename}');"><i class="ui red trash icon"></i></button>
</td>
</tr>`);
});
@@ -413,7 +479,7 @@
document.getElementById('pubkeySelector').value = '';
document.getElementById('prikeySelector').value = '';
document.getElementById('certdomain').value = '';
$("#certdomain").attr("placeholder", "");
uploadPendingPublicKey = undefined;
uploadPendingPrivateKey = undefined;
@@ -439,8 +505,17 @@
function handleDomainKeysUpload(callback=undefined){
let domain = $("#certdomain").val();
if (domain.trim() == ""){
msgbox("Missing domain", false, 5000);
return;
//Check if placeholder has value
if ($("#certdomain").attr("placeholder").trim() != ""){
domain = $("#certdomain").attr("placeholder").trim();
}else{
domain = undefined;
}
if (domain == undefined || domain.trim() == "") {
msgbox("Missing domain", false, 5000);
return;
}
}
if (uploadPendingPublicKey && uploadPendingPrivateKey && typeof uploadPendingPublicKey === 'object' && typeof uploadPendingPrivateKey === 'object') {
const publicKeyForm = new FormData();
@@ -493,6 +568,7 @@
const file = event.target.files[0];
if (ktype == "pub"){
uploadPendingPublicKey = file;
getPossibleCommonNameFromSelectedCertificate();
}else if (ktype == "pri"){
uploadPendingPrivateKey = file;
}