mirror of
https://github.com/tobychui/zoraxy.git
synced 2025-06-30 03:11:44 +02:00
Compare commits
20 Commits
Author | SHA1 | Date | |
---|---|---|---|
7eda6ba501 | |||
2da5ef048f | |||
6c48939316 | |||
544894bbba | |||
153d056bdf | |||
12c1118af9 | |||
67ba143999 | |||
0a8a821394 | |||
36b17ce4cf | |||
519372069f | |||
2f14d6f271 | |||
44ac7144ec | |||
741d3f8de1 | |||
23eca5afae | |||
050fab9481 | |||
3fc92bac27 | |||
594f75da97 | |||
3fbf246fb4 | |||
828af6263d | |||
ab42cec31f |
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,3 +29,6 @@ src/Zoraxy_*_*
|
||||
src/certs/*
|
||||
src/rules/*
|
||||
src/README.md
|
||||
docker/ContainerTester.sh
|
||||
docker/ImagePublisher.sh
|
||||
src/mod/acme/test/stackoverflow.pem
|
23
CHANGELOG.md
23
CHANGELOG.md
@ -1,19 +1,36 @@
|
||||
v2.6.5 Jul 19 2023
|
||||
|
||||
+ Added Import / Export-Feature
|
||||
+ Moved configurationfiles to a separate folder [#26](https://github.com/tobychui/zoraxy/issues/26)
|
||||
+ Added auto-renew with ACME [#6](https://github.com/tobychui/zoraxy/issues/6)
|
||||
+ Fixed Whitelistbug [#18](https://github.com/tobychui/zoraxy/issues/18)
|
||||
+ Added Whois
|
||||
|
||||
# v2.6.4 Jun 15 2023
|
||||
|
||||
+ Added force TLS v1.2 above toggle
|
||||
+ Added trace route
|
||||
+ Added ICMP ping
|
||||
+ Added special routing rules module for up-coming acme integration
|
||||
+ Fixed IPv6 check bug in black/whitelist
|
||||
+ Optimized UI for TCP Proxy
|
||||
|
||||
# v2.6.3 Jun 8 2023
|
||||
|
||||
+ Added X-Forwarded-Proto for automatic proxy detector
|
||||
+ Split blacklist and whitelist from geodb script file
|
||||
+ Optimized compile binary size
|
||||
+ Added access control to TCP proxy
|
||||
+ Added "invalid config detect" in up time monitor for isse #7
|
||||
+ Added "invalid config detect" in up time monitor for isse [#7](https://github.com/tobychui/zoraxy/issues/7)
|
||||
+ Fixed minor bugs in advance stats panel
|
||||
+ Reduced file size of embedded materials
|
||||
|
||||
# v2.6.2 Jun 4 2023
|
||||
|
||||
+ Added advance stats operation tab
|
||||
+ Added statistic reset #13
|
||||
+ Added statistic reset [#13](https://github.com/tobychui/zoraxy/issues/13)
|
||||
+ Added statistic export to csv and json (please use json)
|
||||
+ Make subdomain clickable (not vdir) #12
|
||||
+ Make subdomain clickable (not vdir) [#12](https://github.com/tobychui/zoraxy/issues/12)
|
||||
+ Added TCP Proxy
|
||||
+ Updates SMTP setup UI to make it more straight forward to setup
|
||||
|
||||
|
@ -67,11 +67,7 @@ The installation method is same as Linux. If you are using Raspberry Pi 4 or new
|
||||
The installation method is same as Linux. For other ARM SBCs, please refer to your SBC's CPU architecture and pick the one that is suitable for your device.
|
||||
|
||||
#### Docker
|
||||
Thanks for cyb3rdoc and PassiveLemon for providing support over the Docker installation. You can check out their repo over here.
|
||||
|
||||
[https://github.com/cyb3rdoc/zoraxy-docker](https://github.com/cyb3rdoc/zoraxy-docker)
|
||||
|
||||
[https://github.com/PassiveLemon/zoraxy-docker](https://github.com/PassiveLemon/zoraxy-docker)
|
||||
See the [/docker](https://github.com/tobychui/zoraxy/tree/main/docker) folder for more details
|
||||
|
||||
### External Permission Management Mode
|
||||
|
||||
|
19
docker/Dockerfile
Normal file
19
docker/Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk update && apk upgrade &&\
|
||||
apk add bash curl jq sudo &&\
|
||||
mkdir -p /zoraxy/data/
|
||||
|
||||
VOLUME [ "/zoraxy/data/" ]
|
||||
|
||||
COPY entrypoint.sh /zoraxy/
|
||||
|
||||
RUN chmod +x /zoraxy/entrypoint.sh
|
||||
|
||||
ENV ARGS="-port=:8000 -noauth=false"
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/zoraxy/entrypoint.sh"]
|
21
docker/LICENSE
Normal file
21
docker/LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 PassiveLemon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
67
docker/README.md
Normal file
67
docker/README.md
Normal file
@ -0,0 +1,67 @@
|
||||
[](https://hub.docker.com/r/passivelemon/zoraxy-docker)
|
||||
[](https://hub.docker.com/r/passivelemon/zoraxy-docker)
|
||||
[](https://hub.docker.com/r/passivelemon/zoraxy-docker)
|
||||
[](https://hub.docker.com/r/passivelemon/zoraxy-docker)
|
||||
|
||||
## Setup: </br>
|
||||
Although not required, it is recommended to give Zoraxy a dedicated location on the host to mount the container. That way, the host/user can access them whenever needed. A volume will be created automatically within Docker if a location is not specified. </br>
|
||||
|
||||
You may also need to portforward your 80/443 to allow http and https traffic. If you are accessing the interface from outside of the local network, you may also need to forward your management port. If you know how to do this, great! If not, find the manufacturer of your router and search on how to do that. There are too many to be listed here. </br>
|
||||
|
||||
### Using Docker run </br>
|
||||
```
|
||||
docker run -d --name (container name) -p (ports) -v (path to storage directory):/zoraxy/data/ -e ARGS=(your arguments) -e VERSION=(version) passivelemon/zoraxy-docker:latest
|
||||
```
|
||||
|
||||
### Using Docker Compose </br>
|
||||
```
|
||||
version: '3.3'
|
||||
services:
|
||||
zoraxy-docker:
|
||||
image: passivelemon/zoraxy-docker:latest
|
||||
container_name: (container name)
|
||||
ports:
|
||||
- 80:80 # Http port
|
||||
- 443:443 # Https port
|
||||
- (external):8000 # Management portal port
|
||||
volumes:
|
||||
- (path to storage directory):/zoraxy/data/ # Host directory for Zoraxy file storage
|
||||
environment:
|
||||
ARGS: '(your arguments)' # The arguments to run with Zoraxy. Enter them as they would be entered normally.
|
||||
```
|
||||
|
||||
| Operator | Need | Details |
|
||||
|:-|:-|:-|
|
||||
| `-d` | Yes | will run the container in the background. |
|
||||
| `--name (container name)` | No | Sets the name of the container to the following word. You can change this to whatever you want. |
|
||||
| `-p (ports)` | Yes | Depending on how your network is setup, you may need to portforward 80, 443, and the management port. |
|
||||
| `-v (path to storage directory):/zoraxy/data/` | Recommend | Sets the folder that holds your files. This should be the place you just chose. By default, it will create a Docker volume for the files for persistency but they will not be accessible. |
|
||||
| `-e ARGS=(your arguments)` | No | Sets the arguments to run Zoraxy with. Enter them as you would normally. By default, it is ran with `-port=:8000 -noauth=false` |
|
||||
| `-e VERSION=(version)` | No | Sets the version of Zoraxy that the container will download. Must be a supported version found on the Zoraxy Github. Defaults to the latest if not set. |
|
||||
| `passivelemon/zoraxy-docker:latest` | Yes | The repository on Docker hub. By default, it is the latest version that I have published. |
|
||||
|
||||
## Examples: </br>
|
||||
### Docker Run </br>
|
||||
```
|
||||
docker run -d --name zoraxy -p 80:80 -p 443:443 -p 8005:8000/tcp -v /home/docker/Containers/Zoraxy:/zoraxy/data/ -e ARGS="-port=:8000 -noauth=false" passivelemon/zoraxy-docker:latest
|
||||
```
|
||||
|
||||
### Docker Compose </br>
|
||||
```
|
||||
version: '3.3'
|
||||
services:
|
||||
zoraxy-docker:
|
||||
image: passivelemon/zoraxy-docker:latest
|
||||
container_name: zoraxy
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
- 8005:8000/tcp
|
||||
volumes:
|
||||
- /home/docker/Containers/Zoraxy:/zoraxy/data/
|
||||
environment:
|
||||
ARGS: '-port=:8000 -noauth=false'
|
||||
```
|
||||
|
||||
### Other </br>
|
||||
If the container doesn't start properly, you might be rate limited from GitHub for some amount of time. You can check this by running `curl -s https://api.github.com/repos/tobychui/zoraxy/releases` on the host. If you are, you will just have to wait for a little while or use a VPN. </br>
|
20
docker/entrypoint.sh
Normal file
20
docker/entrypoint.sh
Normal file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd /zoraxy/data/
|
||||
|
||||
if [ "$VERSION" != "" ]; then
|
||||
echo "|| Using release ${VERSION} ||"
|
||||
release=${VERSION}
|
||||
else
|
||||
echo "|| Using latest release ||"
|
||||
# Gets the latest pre-release version tag. Will be updated when official release comes out.
|
||||
release=$(curl -s https://api.github.com/repos/tobychui/zoraxy/releases | jq -r 'map(select(.prerelease)) | .[0].tag_name')
|
||||
fi
|
||||
|
||||
if [ ! -e "/zoraxy/data/zoraxy_linux_amd64-${release}" ]; then
|
||||
echo "|| Downloading version ${release} ||"
|
||||
curl -Lso /zoraxy/data/zoraxy_linux_amd64-${release} https://github.com/tobychui/zoraxy/releases/download/${release}/zoraxy_linux_amd64
|
||||
chmod u+x /zoraxy/data/zoraxy_linux_amd64-${release}
|
||||
fi
|
||||
|
||||
./zoraxy_linux_amd64-${release} ${ARGS}
|
94
src/acme.go
94
src/acme.go
@ -1,10 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/acme"
|
||||
"imuslab.com/zoraxy/mod/dynamicproxy"
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
/*
|
||||
@ -13,23 +21,95 @@ import (
|
||||
This script handle special routing required for acme auto cert renew functions
|
||||
*/
|
||||
|
||||
// Helper function to generate a random port above a specified value
|
||||
func getRandomPort(minPort int) int {
|
||||
return rand.Intn(65535-minPort) + minPort
|
||||
}
|
||||
|
||||
// init the new ACME instance
|
||||
func initACME() *acme.ACMEHandler {
|
||||
log.Println("Starting ACME handler")
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
// Generate a random port above 30000
|
||||
port := getRandomPort(30000)
|
||||
|
||||
// Check if the port is already in use
|
||||
for acme.IsPortInUse(port) {
|
||||
port = getRandomPort(30000)
|
||||
}
|
||||
|
||||
return acme.NewACME("https://acme-staging-v02.api.letsencrypt.org/directory", strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// create the special routing rule for ACME
|
||||
func acmeRegisterSpecialRoutingRule() {
|
||||
log.Println("Assigned temporary port:" + acmeHandler.Getport())
|
||||
|
||||
err := dynamicProxyRouter.AddRoutingRules(&dynamicproxy.RoutingRule{
|
||||
ID: "acme-autorenew",
|
||||
MatchRule: func(r *http.Request) bool {
|
||||
if r.RequestURI == "/.well-known/" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
found, _ := regexp.MatchString("/.well-known/acme-challenge/*", r.RequestURI)
|
||||
return found
|
||||
},
|
||||
RoutingHandler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("HELLO WORLD, THIS IS ACME REQUEST HANDLER"))
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("client: error making http request: %s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
resBody, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("error reading: %s\n", err)
|
||||
return
|
||||
}
|
||||
w.Write(resBody)
|
||||
},
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
UseSystemAccessControl: false,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
16
src/api.go
16
src/api.go
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"imuslab.com/zoraxy/mod/acme/acmewizard"
|
||||
"imuslab.com/zoraxy/mod/auth"
|
||||
"imuslab.com/zoraxy/mod/netstat"
|
||||
"imuslab.com/zoraxy/mod/netutils"
|
||||
@ -59,6 +60,7 @@ func initAPIs() {
|
||||
authRouter.HandleFunc("/api/cert/tlsRequireLatest", handleSetTlsRequireLatest)
|
||||
authRouter.HandleFunc("/api/cert/upload", handleCertUpload)
|
||||
authRouter.HandleFunc("/api/cert/list", handleListCertificate)
|
||||
authRouter.HandleFunc("/api/cert/listdomains", handleListDomains)
|
||||
authRouter.HandleFunc("/api/cert/checkDefault", handleDefaultCertCheck)
|
||||
authRouter.HandleFunc("/api/cert/delete", handleCertRemove)
|
||||
|
||||
@ -135,6 +137,7 @@ func initAPIs() {
|
||||
authRouter.HandleFunc("/api/tools/ipscan", HandleIpScan)
|
||||
authRouter.HandleFunc("/api/tools/traceroute", netutils.HandleTraceRoute)
|
||||
authRouter.HandleFunc("/api/tools/ping", netutils.HandlePing)
|
||||
authRouter.HandleFunc("/api/tools/whois", netutils.HandleWhois)
|
||||
authRouter.HandleFunc("/api/tools/webssh", HandleCreateProxySession)
|
||||
authRouter.HandleFunc("/api/tools/websshSupported", HandleWebSshSupportCheck)
|
||||
authRouter.HandleFunc("/api/tools/wol", HandleWakeOnLan)
|
||||
@ -147,8 +150,21 @@ func initAPIs() {
|
||||
http.HandleFunc("/api/account/reset", HandleAdminAccountResetEmail)
|
||||
http.HandleFunc("/api/account/new", HandleNewPasswordSetup)
|
||||
|
||||
//ACME & Auto Renewer
|
||||
authRouter.HandleFunc("/api/acme/listExpiredDomains", acmeHandler.HandleGetExpiredDomains)
|
||||
authRouter.HandleFunc("/api/acme/obtainCert", AcmeCheckAndHandleRenewCertificate)
|
||||
authRouter.HandleFunc("/api/acme/autoRenew/enable", acmeAutoRenewer.HandleAutoRenewEnable)
|
||||
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/renewPolicy", acmeAutoRenewer.HandleRenewPolicy)
|
||||
authRouter.HandleFunc("/api/acme/autoRenew/renewNow", acmeAutoRenewer.HandleRenewNow)
|
||||
authRouter.HandleFunc("/api/acme/wizard", acmewizard.HandleGuidedStepCheck) //ACME Wizard
|
||||
|
||||
//Others
|
||||
http.HandleFunc("/api/info/x", HandleZoraxyInfo)
|
||||
http.HandleFunc("/api/conf/export", ExportConfigAsZip)
|
||||
http.HandleFunc("/api/conf/import", ImportConfigFromZip)
|
||||
|
||||
//If you got APIs to add, append them here
|
||||
}
|
||||
|
72
src/cert.go
72
src/cert.go
@ -10,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
@ -44,6 +46,7 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string
|
||||
LastModifiedDate string
|
||||
ExpireDate string
|
||||
RemainingDays int
|
||||
}
|
||||
|
||||
results := []*CertInfo{}
|
||||
@ -60,6 +63,7 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
certExpireTime := "Unknown"
|
||||
certBtyes, err := os.ReadFile(certFilepath)
|
||||
expiredIn := 0
|
||||
if err != nil {
|
||||
//Unable to load this file
|
||||
continue
|
||||
@ -70,6 +74,11 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
certExpireTime = cert.NotAfter.Format("2006-01-02 15:04:05")
|
||||
|
||||
duration := cert.NotAfter.Sub(time.Now())
|
||||
|
||||
// Convert the duration to days
|
||||
expiredIn = int(duration.Hours() / 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,6 +87,7 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
Domain: filename,
|
||||
LastModifiedDate: modifiedTime,
|
||||
ExpireDate: certExpireTime,
|
||||
RemainingDays: expiredIn,
|
||||
}
|
||||
|
||||
results = append(results, &thisCertInfo)
|
||||
@ -99,6 +109,64 @@ func handleListCertificate(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
// List all certificates and map all their domains to the cert filename
|
||||
func handleListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
filenames, err := os.ReadDir("./conf/certs/")
|
||||
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
certnameToDomainMap := map[string]string{}
|
||||
for _, filename := range filenames {
|
||||
if filename.IsDir() {
|
||||
continue
|
||||
}
|
||||
certFilepath := filepath.Join("./conf/certs/", filename.Name())
|
||||
|
||||
certBtyes, err := os.ReadFile(certFilepath)
|
||||
if err != nil {
|
||||
// Unable to load this file
|
||||
log.Println("Unable to load certificate: " + certFilepath)
|
||||
continue
|
||||
} else {
|
||||
// Cert loaded. Check its expiry time
|
||||
block, _ := pem.Decode(certBtyes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
certname := strings.TrimSuffix(filepath.Base(certFilepath), filepath.Ext(certFilepath))
|
||||
for _, dnsName := range cert.DNSNames {
|
||||
certnameToDomainMap[dnsName] = certname
|
||||
}
|
||||
certnameToDomainMap[cert.Subject.CommonName] = certname
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requireCompact, _ := utils.GetPara(r, "compact")
|
||||
if requireCompact == "true" {
|
||||
result := make(map[string][]string)
|
||||
|
||||
for key, value := range certnameToDomainMap {
|
||||
if _, ok := result[value]; !ok {
|
||||
result[value] = make([]string, 0)
|
||||
}
|
||||
|
||||
result[value] = append(result[value], key)
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(result)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
return
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(certnameToDomainMap)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
// Handle front-end toggling TLS mode
|
||||
func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) {
|
||||
currentTlsSetting := false
|
||||
@ -205,8 +273,8 @@ func handleCertUpload(w http.ResponseWriter, r *http.Request) {
|
||||
defer file.Close()
|
||||
|
||||
// create file in upload directory
|
||||
os.MkdirAll("./certs", 0775)
|
||||
f, err := os.Create(filepath.Join("./certs", overWriteFilename))
|
||||
os.MkdirAll("./conf/certs", 0775)
|
||||
f, err := os.Create(filepath.Join("./conf/certs", overWriteFilename))
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create file", http.StatusInternalServerError)
|
||||
return
|
||||
|
211
src/config.go
211
src/config.go
@ -1,12 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/dynamicproxy"
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
@ -31,7 +37,7 @@ type Record struct {
|
||||
|
||||
func SaveReverseProxyConfig(proxyConfigRecord *Record) error {
|
||||
//TODO: Make this accept new def types
|
||||
os.MkdirAll("conf", 0775)
|
||||
os.MkdirAll("./conf/proxy/", 0775)
|
||||
filename := getFilenameFromRootName(proxyConfigRecord.Rootname)
|
||||
|
||||
//Generate record
|
||||
@ -39,12 +45,12 @@ func SaveReverseProxyConfig(proxyConfigRecord *Record) error {
|
||||
|
||||
//Write to file
|
||||
js, _ := json.MarshalIndent(thisRecord, "", " ")
|
||||
return ioutil.WriteFile(filepath.Join("conf", filename), js, 0775)
|
||||
return ioutil.WriteFile(filepath.Join("./conf/proxy/", filename), js, 0775)
|
||||
}
|
||||
|
||||
func RemoveReverseProxyConfig(rootname string) error {
|
||||
filename := getFilenameFromRootName(rootname)
|
||||
removePendingFile := strings.ReplaceAll(filepath.Join("conf", filename), "\\", "/")
|
||||
removePendingFile := strings.ReplaceAll(filepath.Join("./conf/proxy/", filename), "\\", "/")
|
||||
log.Println("Config Removed: ", removePendingFile)
|
||||
if utils.FileExists(removePendingFile) {
|
||||
err := os.Remove(removePendingFile)
|
||||
@ -83,3 +89,202 @@ func getFilenameFromRootName(rootname string) string {
|
||||
filename = filename + ".config"
|
||||
return filename
|
||||
}
|
||||
|
||||
/*
|
||||
Importer and Exporter of Zoraxy proxy config
|
||||
*/
|
||||
|
||||
func ExportConfigAsZip(w http.ResponseWriter, r *http.Request) {
|
||||
includeSysDBRaw, err := utils.GetPara(r, "includeDB")
|
||||
includeSysDB := false
|
||||
if includeSysDBRaw == "true" {
|
||||
//Include the system database in backup snapshot
|
||||
//Temporary set it to read only
|
||||
sysdb.ReadOnly = true
|
||||
includeSysDB = true
|
||||
}
|
||||
|
||||
// Specify the folder path to be zipped
|
||||
folderPath := "./conf/"
|
||||
|
||||
// Set the Content-Type header to indicate it's a zip file
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
// Set the Content-Disposition header to specify the file name
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"config.zip\"")
|
||||
|
||||
// Create a zip writer
|
||||
zipWriter := zip.NewWriter(w)
|
||||
defer zipWriter.Close()
|
||||
|
||||
// Walk through the folder and add files to the zip
|
||||
err = filepath.Walk(folderPath, func(filePath string, fileInfo os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if folderPath == filePath {
|
||||
//Skip root folder
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a new file in the zip
|
||||
if !utils.IsDir(filePath) {
|
||||
zipFile, err := zipWriter.Create(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the file on disk
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Copy the file contents to the zip file
|
||||
_, err = io.Copy(zipFile, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if includeSysDB {
|
||||
//Also zip in the sysdb
|
||||
zipFile, err := zipWriter.Create("sys.db")
|
||||
if err != nil {
|
||||
log.Println("[Backup] Unable to zip sysdb: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Open the file on disk
|
||||
file, err := os.Open("sys.db")
|
||||
if err != nil {
|
||||
log.Println("[Backup] Unable to open sysdb: " + err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Copy the file contents to the zip file
|
||||
_, err = io.Copy(zipFile, file)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
//Restore sysdb state
|
||||
sysdb.ReadOnly = false
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Handle the error and send an HTTP response with the error message
|
||||
http.Error(w, fmt.Sprintf("Failed to zip folder: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if the request is a POST with a file upload
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Invalid request method", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Max file size limit (10 MB in this example)
|
||||
r.ParseMultipartForm(10 << 20)
|
||||
|
||||
// Get the uploaded file
|
||||
file, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to retrieve uploaded file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if filepath.Ext(handler.Filename) != ".zip" {
|
||||
http.Error(w, "Upload file is not a zip file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Create the target directory to unzip the files
|
||||
targetDir := "./conf"
|
||||
if utils.FileExists(targetDir) {
|
||||
//Backup the old config to old
|
||||
os.Rename("./conf", "./conf.old_"+strconv.Itoa(int(time.Now().Unix())))
|
||||
}
|
||||
|
||||
err = os.MkdirAll(targetDir, os.ModePerm)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create target directory: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Open the zip file
|
||||
zipReader, err := zip.NewReader(file, handler.Size)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to open zip file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
restoreDatabase := false
|
||||
|
||||
// Extract each file from the zip archive
|
||||
for _, zipFile := range zipReader.File {
|
||||
// Open the file in the zip archive
|
||||
rc, err := zipFile.Open()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to open file in zip: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// Create the corresponding file on disk
|
||||
zipFile.Name = strings.ReplaceAll(zipFile.Name, "../", "")
|
||||
fmt.Println("Restoring: " + strings.ReplaceAll(zipFile.Name, "\\", "/"))
|
||||
if zipFile.Name == "sys.db" {
|
||||
//Sysdb replacement. Close the database and restore
|
||||
sysdb.Close()
|
||||
restoreDatabase = true
|
||||
} else if !strings.HasPrefix(strings.ReplaceAll(zipFile.Name, "\\", "/"), "conf/") {
|
||||
//Malformed zip file.
|
||||
http.Error(w, fmt.Sprintf("Invalid zip file structure or version too old"), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
//Check if parent dir exists
|
||||
if !utils.FileExists(filepath.Dir(zipFile.Name)) {
|
||||
os.MkdirAll(filepath.Dir(zipFile.Name), 0775)
|
||||
}
|
||||
|
||||
//Create the file
|
||||
newFile, err := os.Create(zipFile.Name)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer newFile.Close()
|
||||
|
||||
// Copy the file contents from the zip to the new file
|
||||
_, err = io.Copy(newFile, rc)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to extract file from zip: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send a success response
|
||||
w.WriteHeader(http.StatusOK)
|
||||
log.Println("Configuration restored")
|
||||
fmt.Fprintln(w, "Configuration restored")
|
||||
|
||||
if restoreDatabase {
|
||||
go func() {
|
||||
log.Println("Database altered. Restarting in 3 seconds...")
|
||||
time.Sleep(3 * time.Second)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -4,14 +4,16 @@ go 1.16
|
||||
|
||||
require (
|
||||
github.com/boltdb/bolt v1.3.1
|
||||
github.com/go-acme/lego/v4 v4.12.1 // indirect
|
||||
github.com/go-ping/ping v1.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/grandcat/zeroconf v1.0.0
|
||||
github.com/likexian/whois v1.15.0 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.24
|
||||
github.com/oschwald/geoip2-golang v1.8.0
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
golang.org/x/net v0.10.0
|
||||
golang.org/x/sys v0.8.0
|
||||
golang.org/x/net v0.11.0
|
||||
golang.org/x/sys v0.9.0
|
||||
)
|
||||
|
1597
src/go.sum
1597
src/go.sum
File diff suppressed because it is too large
Load Diff
49
src/main.go
49
src/main.go
@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"imuslab.com/zoraxy/mod/acme"
|
||||
"imuslab.com/zoraxy/mod/aroz"
|
||||
"imuslab.com/zoraxy/mod/auth"
|
||||
"imuslab.com/zoraxy/mod/database"
|
||||
@ -37,9 +38,10 @@ 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 (seconds)")
|
||||
var (
|
||||
name = "Zoraxy"
|
||||
version = "2.6.4"
|
||||
version = "2.6.5"
|
||||
nodeUUID = "generic"
|
||||
development = false //Set this to false to use embedded web fs
|
||||
bootTime = time.Now().Unix()
|
||||
@ -67,6 +69,8 @@ var (
|
||||
ganManager *ganserv.NetworkManager //Global Area Network Manager
|
||||
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
|
||||
@ -79,29 +83,34 @@ func SetupCloseHandler() {
|
||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-c
|
||||
fmt.Println("- Shutting down " + name)
|
||||
fmt.Println("- Closing GeoDB ")
|
||||
geodbStore.Close()
|
||||
fmt.Println("- Closing Netstats Listener")
|
||||
netstatBuffers.Close()
|
||||
fmt.Println("- Closing Statistic Collector")
|
||||
statisticCollector.Close()
|
||||
fmt.Println("- Stopping mDNS Discoverer")
|
||||
//Stop the mdns service
|
||||
mdnsTickerStop <- true
|
||||
mdnsScanner.Close()
|
||||
|
||||
//Remove the tmp folder
|
||||
fmt.Println("- Cleaning up tmp files")
|
||||
os.RemoveAll("./tmp")
|
||||
|
||||
//Close database, final
|
||||
fmt.Println("- Stopping system database")
|
||||
sysdb.Close()
|
||||
ShutdownSeq()
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
func ShutdownSeq() {
|
||||
fmt.Println("- Shutting down " + name)
|
||||
fmt.Println("- Closing GeoDB ")
|
||||
geodbStore.Close()
|
||||
fmt.Println("- Closing Netstats Listener")
|
||||
netstatBuffers.Close()
|
||||
fmt.Println("- Closing Statistic Collector")
|
||||
statisticCollector.Close()
|
||||
fmt.Println("- Stopping mDNS Discoverer")
|
||||
//Stop the mdns service
|
||||
mdnsTickerStop <- true
|
||||
mdnsScanner.Close()
|
||||
fmt.Println("- Closing Certificates Auto Renewer")
|
||||
acmeAutoRenewer.Close()
|
||||
//Remove the tmp folder
|
||||
fmt.Println("- Cleaning up tmp files")
|
||||
os.RemoveAll("./tmp")
|
||||
|
||||
//Close database, final
|
||||
fmt.Println("- Stopping system database")
|
||||
sysdb.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
//Start the aoModule pipeline (which will parse the flags as well). Pass in the module launch information
|
||||
handler = aroz.HandleFlagParse(aroz.ServiceInfo{
|
||||
|
288
src/mod/acme/acme.go
Normal file
288
src/mod/acme/acme.go
Normal file
@ -0,0 +1,288 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ACMEUser represents a user in the ACME system.
|
||||
type ACMEUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
// GetEmail returns the email of the ACMEUser.
|
||||
func (u *ACMEUser) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
// GetRegistration returns the registration resource of the ACMEUser.
|
||||
func (u ACMEUser) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
|
||||
// GetPrivateKey returns the private key of the ACMEUser.
|
||||
func (u *ACMEUser) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
// ACMEHandler handles ACME-related operations.
|
||||
type ACMEHandler struct {
|
||||
DefaultAcmeServer string
|
||||
Port string
|
||||
}
|
||||
|
||||
// NewACME creates a new ACMEHandler instance.
|
||||
func NewACME(acmeServer string, port string) *ACMEHandler {
|
||||
return &ACMEHandler{
|
||||
DefaultAcmeServer: acmeServer,
|
||||
Port: port,
|
||||
}
|
||||
}
|
||||
|
||||
// ObtainCert obtains a certificate for the specified domains.
|
||||
func (a *ACMEHandler) ObtainCert(domains []string, certificateName string, email string, ca string) (bool, error) {
|
||||
log.Println("[ACME] Obtaining certificate...")
|
||||
|
||||
// generate private key
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// create a admin user for our new generation
|
||||
adminUser := ACMEUser{
|
||||
Email: email,
|
||||
key: privateKey,
|
||||
}
|
||||
|
||||
// create config
|
||||
config := lego.NewConfig(&adminUser)
|
||||
|
||||
// setup who is the issuer and the key type
|
||||
config.CADirURL = a.DefaultAcmeServer
|
||||
|
||||
//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")
|
||||
} else {
|
||||
return false, errors.New("CA " + ca + " is not supported. Please contribute to the source code and add this CA's directory link.")
|
||||
}
|
||||
}
|
||||
|
||||
config.Certificate.KeyType = certcrypto.RSA2048
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// setup how to receive challenge
|
||||
err = client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", a.Port))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
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 false, err
|
||||
}
|
||||
adminUser.Registration = reg
|
||||
|
||||
// obtain the certificate
|
||||
request := certificate.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
}
|
||||
certificates, err := client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Each certificate comes back with the cert bytes, the bytes of the client's
|
||||
// private key, and a certificate URL.
|
||||
err = ioutil.WriteFile("./conf/certs/"+certificateName+".crt", certificates.Certificate, 0777)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return false, err
|
||||
}
|
||||
err = ioutil.WriteFile("./conf/certs/"+certificateName+".key", certificates.PrivateKey, 0777)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// CheckCertificate returns a list of domains that are in expired certificates.
|
||||
// It will return all domains that is in expired certificates
|
||||
// *** if there is a vaild certificate contains the domain and there is a expired certificate contains the same domain
|
||||
// it will said expired as well!
|
||||
func (a *ACMEHandler) CheckCertificate() []string {
|
||||
// read from dir
|
||||
filenames, err := os.ReadDir("./conf/certs/")
|
||||
|
||||
expiredCerts := []string{}
|
||||
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return []string{}
|
||||
}
|
||||
|
||||
for _, filename := range filenames {
|
||||
certFilepath := filepath.Join("./conf/certs/", filename.Name())
|
||||
|
||||
certBytes, err := os.ReadFile(certFilepath)
|
||||
if err != nil {
|
||||
// Unable to load this file
|
||||
continue
|
||||
} else {
|
||||
// Cert loaded. Check its expiry time
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
elapsed := time.Since(cert.NotAfter)
|
||||
if elapsed > 0 {
|
||||
// if it is expired then add it in
|
||||
// make sure it's uniqueless
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCerts
|
||||
}
|
||||
|
||||
// return the current port number
|
||||
func (a *ACMEHandler) Getport() string {
|
||||
return a.Port
|
||||
}
|
||||
|
||||
// contains checks if a string is present in a slice.
|
||||
func contains(slice []string, str string) bool {
|
||||
for _, s := range slice {
|
||||
if s == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleGetExpiredDomains handles the HTTP GET request to retrieve the list of expired domains.
|
||||
// It calls the CheckCertificate method to obtain the expired domains and sends a JSON response
|
||||
// containing the list of expired domains.
|
||||
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))
|
||||
}
|
||||
|
||||
// HandleRenewCertificate handles the HTTP GET request to renew a certificate for the provided domains.
|
||||
// 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.PostPara(r, "domains")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, jsonEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
filename, err := utils.PostPara(r, "filename")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, jsonEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
email, err := utils.PostPara(r, "email")
|
||||
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, email, ca)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, jsonEscape(err.Error()))
|
||||
return
|
||||
}
|
||||
utils.SendJSONResponse(w, strconv.FormatBool(result))
|
||||
}
|
||||
|
||||
// Escape JSON string
|
||||
func jsonEscape(i string) string {
|
||||
b, err := json.Marshal(i)
|
||||
if err != nil {
|
||||
log.Println("Unable to escape json data: " + err.Error())
|
||||
return i
|
||||
}
|
||||
s := string(b)
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
24
src/mod/acme/acme_test.go
Normal file
24
src/mod/acme/acme_test.go
Normal file
@ -0,0 +1,24 @@
|
||||
package acme_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"imuslab.com/zoraxy/mod/acme"
|
||||
)
|
||||
|
||||
// Test if the issuer extraction is working
|
||||
func TestExtractIssuerNameFromPEM(t *testing.T) {
|
||||
pemFilePath := "test/stackoverflow.pem"
|
||||
expectedIssuer := "Let's Encrypt"
|
||||
|
||||
issuerName, err := acme.ExtractIssuerNameFromPEM(pemFilePath)
|
||||
fmt.Println(issuerName)
|
||||
if err != nil {
|
||||
t.Errorf("Error extracting issuer name: %v", err)
|
||||
}
|
||||
|
||||
if issuerName != expectedIssuer {
|
||||
t.Errorf("Unexpected issuer name. Expected: %s, Got: %s", expectedIssuer, issuerName)
|
||||
}
|
||||
}
|
163
src/mod/acme/acmewizard/acmewizard.go
Normal file
163
src/mod/acme/acmewizard/acmewizard.go
Normal file
@ -0,0 +1,163 @@
|
||||
package acmewizard
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
/*
|
||||
ACME Wizard
|
||||
|
||||
This wizard help validate the acme settings and configurations
|
||||
*/
|
||||
|
||||
func HandleGuidedStepCheck(w http.ResponseWriter, r *http.Request) {
|
||||
stepNoStr, err := utils.GetPara(r, "step")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "invalid step number given")
|
||||
return
|
||||
}
|
||||
|
||||
stepNo, err := strconv.Atoi(stepNoStr)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "invalid step number given")
|
||||
return
|
||||
}
|
||||
|
||||
if stepNo == 1 {
|
||||
isListening, err := isLocalhostListening()
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(isListening)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else if stepNo == 2 {
|
||||
publicIp, err := getPublicIPAddress()
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
publicIp = strings.TrimSpace(publicIp)
|
||||
|
||||
httpServerReachable := isHTTPServerAvailable(publicIp)
|
||||
|
||||
js, _ := json.Marshal(httpServerReachable)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else if stepNo == 3 {
|
||||
domain, err := utils.GetPara(r, "domain")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "domain cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
domain = strings.TrimSpace(domain)
|
||||
|
||||
//Check if the domain is reachable
|
||||
reachable := isDomainReachable(domain)
|
||||
if !reachable {
|
||||
utils.SendErrorResponse(w, "domain is not reachable")
|
||||
return
|
||||
}
|
||||
|
||||
//Check http is setup correctly
|
||||
httpServerReachable := isHTTPServerAvailable(domain)
|
||||
js, _ := json.Marshal(httpServerReachable)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else {
|
||||
utils.SendErrorResponse(w, "invalid step number")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1
|
||||
func isLocalhostListening() (isListening bool, err error) {
|
||||
timeout := 2 * time.Second
|
||||
isListening = false
|
||||
// Check if localhost is listening on port 80 (HTTP)
|
||||
conn, err := net.DialTimeout("tcp", "localhost:80", timeout)
|
||||
if err == nil {
|
||||
isListening = true
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
// Check if localhost is listening on port 443 (HTTPS)
|
||||
conn, err = net.DialTimeout("tcp", "localhost:443", timeout)
|
||||
if err == nil {
|
||||
isListening = true
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
if isListening {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return isListening, err
|
||||
}
|
||||
|
||||
// Step 2
|
||||
func getPublicIPAddress() (string, error) {
|
||||
resp, err := http.Get("http://checkip.amazonaws.com/")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ip, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(ip), nil
|
||||
}
|
||||
|
||||
func isHTTPServerAvailable(ipAddress string) bool {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second, // Timeout for the HTTP request
|
||||
}
|
||||
|
||||
urls := []string{
|
||||
"http://" + ipAddress + ":80",
|
||||
"https://" + ipAddress + ":443",
|
||||
}
|
||||
|
||||
for _, url := range urls {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
fmt.Println(err, url)
|
||||
continue // Ignore invalid URLs
|
||||
}
|
||||
|
||||
// Disable TLS verification to handle invalid certificates
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
return true // HTTP server is available
|
||||
}
|
||||
}
|
||||
|
||||
return false // HTTP server is not available
|
||||
}
|
||||
|
||||
// Step 3
|
||||
func isDomainReachable(domain string) bool {
|
||||
_, err := net.LookupHost(domain)
|
||||
if err != nil {
|
||||
return false // Domain is not reachable
|
||||
}
|
||||
return true // Domain is reachable
|
||||
}
|
374
src/mod/acme/autorenew.go
Normal file
374
src/mod/acme/autorenew.go
Normal file
@ -0,0 +1,374 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
/*
|
||||
autorenew.go
|
||||
|
||||
This script handle auto renew
|
||||
*/
|
||||
|
||||
type AutoRenewConfig struct {
|
||||
Enabled bool //Automatic renew is enabled
|
||||
Email string //Email for acme
|
||||
RenewAll bool //Renew all or selective renew with the slice below
|
||||
FilesToRenew []string //If RenewAll is false, renew these certificate files
|
||||
}
|
||||
|
||||
type AutoRenewer struct {
|
||||
ConfigFilePath string
|
||||
CertFolder string
|
||||
AcmeHandler *ACMEHandler
|
||||
RenewerConfig *AutoRenewConfig
|
||||
RenewTickInterval int64
|
||||
TickerstopChan chan bool
|
||||
}
|
||||
|
||||
type ExpiredCerts struct {
|
||||
Domains []string
|
||||
Filepath string
|
||||
CA string
|
||||
}
|
||||
|
||||
// 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, AcmeHandler *ACMEHandler) (*AutoRenewer, error) {
|
||||
if renewCheckInterval == 0 {
|
||||
renewCheckInterval = 86400 //1 day
|
||||
}
|
||||
|
||||
//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,
|
||||
FilesToRenew: []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,
|
||||
AcmeHandler: AcmeHandler,
|
||||
RenewerConfig: &renewerConfig,
|
||||
RenewTickInterval: renewCheckInterval,
|
||||
}
|
||||
|
||||
if thisRenewer.RenewerConfig.Enabled {
|
||||
//Start the renew ticker
|
||||
thisRenewer.StartAutoRenewTicker()
|
||||
|
||||
//Check and renew certificate on startup
|
||||
go thisRenewer.CheckAndRenewCertificates()
|
||||
}
|
||||
|
||||
return &thisRenewer, nil
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) StartAutoRenewTicker() {
|
||||
//Stop the previous ticker if still running
|
||||
if a.TickerstopChan != nil {
|
||||
a.TickerstopChan <- true
|
||||
}
|
||||
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(time.Duration(a.RenewTickInterval) * time.Second)
|
||||
done := make(chan bool)
|
||||
|
||||
//Start the ticker to check and renew every x seconds
|
||||
go func(a *AutoRenewer) {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
log.Println("Check and renew certificates in progress")
|
||||
a.CheckAndRenewCertificates()
|
||||
}
|
||||
}
|
||||
}(a)
|
||||
|
||||
a.TickerstopChan = done
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) StopAutoRenewTicker() {
|
||||
if a.TickerstopChan != nil {
|
||||
a.TickerstopChan <- true
|
||||
}
|
||||
|
||||
a.TickerstopChan = nil
|
||||
}
|
||||
|
||||
// Handle update auto renew domains
|
||||
// Set opr for different mode of operations
|
||||
// opr = setSelected -> Enter a list of file names (or matching rules) for auto renew
|
||||
// opr = setAuto -> Set to use auto detect certificates and renew
|
||||
func (a *AutoRenewer) HandleSetAutoRenewDomains(w http.ResponseWriter, r *http.Request) {
|
||||
opr, err := utils.GetPara(r, "opr")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "Operation not set")
|
||||
return
|
||||
}
|
||||
|
||||
if opr == "setSelected" {
|
||||
files, err := utils.PostPara(r, "domains")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "Domains is not defined")
|
||||
return
|
||||
}
|
||||
|
||||
//Parse it int array of string
|
||||
matchingRuleFiles := []string{}
|
||||
err = json.Unmarshal([]byte(files), &matchingRuleFiles)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//Update the configs
|
||||
a.RenewerConfig.RenewAll = false
|
||||
a.RenewerConfig.FilesToRenew = matchingRuleFiles
|
||||
a.saveRenewConfigToFile()
|
||||
utils.SendOK(w)
|
||||
} else if opr == "setAuto" {
|
||||
a.RenewerConfig.RenewAll = true
|
||||
a.saveRenewConfigToFile()
|
||||
utils.SendOK(w)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// if auto renew all is true (aka auto scan), it will return []string{"*"}
|
||||
func (a *AutoRenewer) HandleLoadAutoRenewDomains(w http.ResponseWriter, r *http.Request) {
|
||||
results := []string{}
|
||||
if a.RenewerConfig.RenewAll {
|
||||
//Auto pick which cert to renew.
|
||||
results = append(results, "*")
|
||||
} else {
|
||||
//Manually set the files to renew
|
||||
results = a.RenewerConfig.FilesToRenew
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(results)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) HandleRenewPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
//Load the current value
|
||||
js, _ := json.Marshal(a.RenewerConfig.RenewAll)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) HandleRenewNow(w http.ResponseWriter, r *http.Request) {
|
||||
renewedDomains, err := a.CheckAndRenewCertificates()
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
message := "Domains renewed"
|
||||
if len(renewedDomains) == 0 {
|
||||
message = ("All certificates are up-to-date!")
|
||||
} else {
|
||||
message = ("The following domains have been renewed: " + strings.Join(renewedDomains, ","))
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(message)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) HandleAutoRenewEnable(w http.ResponseWriter, r *http.Request) {
|
||||
val, err := utils.PostPara(r, "enable")
|
||||
if err != nil {
|
||||
js, _ := json.Marshal(a.RenewerConfig.Enabled)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else {
|
||||
if val == "true" {
|
||||
//Check if the email is not empty
|
||||
if a.RenewerConfig.Email == "" {
|
||||
utils.SendErrorResponse(w, "Email is not set")
|
||||
return
|
||||
}
|
||||
|
||||
a.RenewerConfig.Enabled = true
|
||||
a.saveRenewConfigToFile()
|
||||
log.Println("[ACME] ACME auto renew enabled")
|
||||
a.StartAutoRenewTicker()
|
||||
} else {
|
||||
a.RenewerConfig.Enabled = false
|
||||
a.saveRenewConfigToFile()
|
||||
log.Println("[ACME] ACME auto renew disabled")
|
||||
a.StopAutoRenewTicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) HandleACMEEmail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
email, err := utils.PostPara(r, "set")
|
||||
if err != nil {
|
||||
//Return the current email to user
|
||||
js, _ := json.Marshal(a.RenewerConfig.Email)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else {
|
||||
//Check if the email is valid
|
||||
_, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
//Set the new config
|
||||
a.RenewerConfig.Email = email
|
||||
a.saveRenewConfigToFile()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
expiredCertList := []*ExpiredCerts{}
|
||||
if a.RenewerConfig.RenewAll {
|
||||
//Scan and renew all
|
||||
for _, file := range files {
|
||||
if filepath.Ext(file.Name()) == ".crt" || filepath.Ext(file.Name()) == ".pem" {
|
||||
//This is a public key file
|
||||
certBytes, err := os.ReadFile(filepath.Join(certFolder, file.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if CertExpireSoon(certBytes) || CertIsExpired(certBytes) {
|
||||
//This cert is expired
|
||||
CAName, err := ExtractIssuerName(certBytes)
|
||||
if err != nil {
|
||||
//Maybe self signed. Ignore this
|
||||
log.Println("Unable to extract issuer name for cert " + file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
DNSName, err := ExtractDomains(certBytes)
|
||||
if err != nil {
|
||||
//Maybe self signed. Ignore this
|
||||
log.Println("Encounted error when trying to resolve DNS name for cert " + file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
expiredCertList = append(expiredCertList, &ExpiredCerts{
|
||||
Filepath: filepath.Join(certFolder, file.Name()),
|
||||
CA: CAName,
|
||||
Domains: DNSName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//Only renew those in the list
|
||||
for _, file := range files {
|
||||
fileName := file.Name()
|
||||
certName := fileName[:len(fileName)-len(filepath.Ext(fileName))]
|
||||
if contains(a.RenewerConfig.FilesToRenew, certName) {
|
||||
//This is the one to auto renew
|
||||
certBytes, err := os.ReadFile(filepath.Join(certFolder, file.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if CertExpireSoon(certBytes) || CertIsExpired(certBytes) {
|
||||
//This cert is expired
|
||||
CAName, err := ExtractIssuerName(certBytes)
|
||||
if err != nil {
|
||||
//Maybe self signed. Ignore this
|
||||
log.Println("Unable to extract issuer name for cert " + file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
DNSName, err := ExtractDomains(certBytes)
|
||||
if err != nil {
|
||||
//Maybe self signed. Ignore this
|
||||
log.Println("Encounted error when trying to resolve DNS name for cert " + file.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
expiredCertList = append(expiredCertList, &ExpiredCerts{
|
||||
Filepath: filepath.Join(certFolder, file.Name()),
|
||||
CA: CAName,
|
||||
Domains: DNSName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return a.renewExpiredDomains(expiredCertList)
|
||||
}
|
||||
|
||||
func (a *AutoRenewer) Close() {
|
||||
if a.TickerstopChan != nil {
|
||||
a.TickerstopChan <- true
|
||||
}
|
||||
}
|
||||
|
||||
// Renew the certificate by filename extract all DNS name from the
|
||||
// certificate and renew them one by one by calling to the acmeHandler
|
||||
func (a *AutoRenewer) renewExpiredDomains(certs []*ExpiredCerts) ([]string, error) {
|
||||
renewedCertFiles := []string{}
|
||||
for _, expiredCert := range certs {
|
||||
log.Println("Renewing " + expiredCert.Filepath + " (Might take a few minutes)")
|
||||
fileName := filepath.Base(expiredCert.Filepath)
|
||||
certName := fileName[:len(fileName)-len(filepath.Ext(fileName))]
|
||||
_, err := a.AcmeHandler.ObtainCert(expiredCert.Domains, certName, a.RenewerConfig.Email, expiredCert.CA)
|
||||
if err != nil {
|
||||
log.Println("Renew " + fileName + "(" + strings.Join(expiredCert.Domains, ",") + ") failed: " + err.Error())
|
||||
} else {
|
||||
log.Println("Successfully renewed " + filepath.Base(expiredCert.Filepath))
|
||||
renewedCertFiles = append(renewedCertFiles, filepath.Base(expiredCert.Filepath))
|
||||
}
|
||||
}
|
||||
|
||||
return renewedCertFiles, nil
|
||||
}
|
||||
|
||||
// Write the current renewer config to file
|
||||
func (a *AutoRenewer) saveRenewConfigToFile() error {
|
||||
js, _ := json.MarshalIndent(a.RenewerConfig, "", " ")
|
||||
return os.WriteFile(a.ConfigFilePath, js, 0775)
|
||||
}
|
45
src/mod/acme/ca.go
Normal file
45
src/mod/acme/ca.go
Normal file
@ -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
|
||||
}
|
15
src/mod/acme/ca.json
Normal file
15
src/mod/acme/ca.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"production": {
|
||||
"Let's Encrypt": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"Buypass": "https://api.buypass.com/acme/directory",
|
||||
"ZeroSSL": "https://acme.zerossl.com/v2/DV90",
|
||||
"Google": "https://dv.acme-v02.api.pki.goog/directory"
|
||||
},
|
||||
"test":{
|
||||
"Let's Encrypt": "https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
"Buypass": "https://api.test4.buypass.no/acme/directory",
|
||||
"Google": "https://dv.acme-v02.test-api.pki.goog/directory"
|
||||
}
|
||||
}
|
||||
|
||||
|
94
src/mod/acme/utils.go
Normal file
94
src/mod/acme/utils.go
Normal file
@ -0,0 +1,94 @@
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Get the issuer name from pem file
|
||||
func ExtractIssuerNameFromPEM(pemFilePath string) (string, error) {
|
||||
// Read the PEM file
|
||||
pemData, err := ioutil.ReadFile(pemFilePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ExtractIssuerName(pemData)
|
||||
}
|
||||
|
||||
// Get the DNSName in the cert
|
||||
func ExtractDomains(certBytes []byte) ([]string, error) {
|
||||
domains := []string{}
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
for _, dnsName := range cert.DNSNames {
|
||||
if !contains(domains, dnsName) {
|
||||
domains = append(domains, dnsName)
|
||||
}
|
||||
}
|
||||
|
||||
return domains, nil
|
||||
}
|
||||
return []string{}, errors.New("decode cert bytes failed")
|
||||
}
|
||||
|
||||
func ExtractIssuerName(certBytes []byte) (string, error) {
|
||||
// Parse the PEM block
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", fmt.Errorf("failed to decode PEM block containing certificate")
|
||||
}
|
||||
|
||||
// Parse the certificate
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse certificate: %v", err)
|
||||
}
|
||||
|
||||
// Extract the issuer name
|
||||
issuer := cert.Issuer.Organization[0]
|
||||
|
||||
return issuer, nil
|
||||
}
|
||||
|
||||
// Check if a cert is expired by public key
|
||||
func CertIsExpired(certBytes []byte) bool {
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
elapsed := time.Since(cert.NotAfter)
|
||||
if elapsed > 0 {
|
||||
// if it is expired then add it in
|
||||
// make sure it's uniqueless
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func CertExpireSoon(certBytes []byte) bool {
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block != nil {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err == nil {
|
||||
expirationDate := cert.NotAfter
|
||||
threshold := 14 * 24 * time.Hour // 14 days
|
||||
|
||||
timeRemaining := time.Until(expirationDate)
|
||||
if timeRemaining <= threshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
@ -23,35 +23,32 @@ import (
|
||||
|
||||
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
/*
|
||||
General Access Check
|
||||
Special Routing Rules, bypass most of the limitations
|
||||
*/
|
||||
|
||||
//Check if this ip is in blacklist
|
||||
clientIpAddr := geodb.GetRequesterIP(r)
|
||||
if h.Parent.Option.GeodbStore.IsBlacklisted(clientIpAddr) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
template, err := os.ReadFile("./web/forbidden.html")
|
||||
if err != nil {
|
||||
w.Write([]byte("403 - Forbidden"))
|
||||
} else {
|
||||
w.Write(template)
|
||||
//Check if there are external routing rule matches.
|
||||
//If yes, route them via external rr
|
||||
matchedRoutingRule := h.Parent.GetMatchingRoutingRule(r)
|
||||
if matchedRoutingRule != nil {
|
||||
//Matching routing rule found. Let the sub-router handle it
|
||||
if matchedRoutingRule.UseSystemAccessControl {
|
||||
//This matching rule request system access control.
|
||||
//check access logic
|
||||
respWritten := h.handleAccessRouting(w, r)
|
||||
if respWritten {
|
||||
return
|
||||
}
|
||||
}
|
||||
h.logRequest(r, false, 403, "blacklist", "")
|
||||
matchedRoutingRule.Route(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
//Check if this ip is in whitelist
|
||||
if !h.Parent.Option.GeodbStore.IsWhitelisted(clientIpAddr) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
template, err := os.ReadFile("./web/forbidden.html")
|
||||
if err != nil {
|
||||
w.Write([]byte("403 - Forbidden"))
|
||||
} else {
|
||||
w.Write(template)
|
||||
}
|
||||
h.logRequest(r, false, 403, "whitelist", "")
|
||||
/*
|
||||
General Access Check
|
||||
*/
|
||||
|
||||
respWritten := h.handleAccessRouting(w, r)
|
||||
if respWritten {
|
||||
return
|
||||
}
|
||||
|
||||
@ -65,15 +62,6 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
//Check if there are external routing rule matches.
|
||||
//If yes, route them via external rr
|
||||
matchedRoutingRule := h.Parent.GetMatchingRoutingRule(r)
|
||||
if matchedRoutingRule != nil {
|
||||
//Matching routing rule found. Let the sub-router handle it
|
||||
matchedRoutingRule.Route(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
//Extract request host to see if it is virtual directory or subdomain
|
||||
domainOnly := r.Host
|
||||
if strings.Contains(r.Host, ":") {
|
||||
@ -127,3 +115,38 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.proxyRequest(w, r, h.Parent.Root)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle access routing logic. Return true if the request is handled or blocked by the access control logic
|
||||
// if the return value is false, you can continue process the response writer
|
||||
func (h *ProxyHandler) handleAccessRouting(w http.ResponseWriter, r *http.Request) bool {
|
||||
//Check if this ip is in blacklist
|
||||
clientIpAddr := geodb.GetRequesterIP(r)
|
||||
if h.Parent.Option.GeodbStore.IsBlacklisted(clientIpAddr) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
template, err := os.ReadFile("./web/forbidden.html")
|
||||
if err != nil {
|
||||
w.Write([]byte("403 - Forbidden"))
|
||||
} else {
|
||||
w.Write(template)
|
||||
}
|
||||
h.logRequest(r, false, 403, "blacklist", "")
|
||||
return true
|
||||
}
|
||||
|
||||
//Check if this ip is in whitelist
|
||||
if !h.Parent.Option.GeodbStore.IsWhitelisted(clientIpAddr) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
template, err := os.ReadFile("./web/forbidden.html")
|
||||
if err != nil {
|
||||
w.Write([]byte("403 - Forbidden"))
|
||||
} else {
|
||||
w.Write(template)
|
||||
}
|
||||
h.logRequest(r, false, 403, "whitelist", "")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -91,11 +91,12 @@ func NewDynamicProxyCore(target *url.URL, prepender string, ignoreTLSVerificatio
|
||||
|
||||
//Hack the default transporter to handle more connections
|
||||
thisTransporter := http.DefaultTransport
|
||||
thisTransporter.(*http.Transport).MaxIdleConns = 3000
|
||||
thisTransporter.(*http.Transport).MaxIdleConnsPerHost = 3000
|
||||
thisTransporter.(*http.Transport).IdleConnTimeout = 10 * time.Second
|
||||
thisTransporter.(*http.Transport).MaxConnsPerHost = 0
|
||||
//thisTransporter.(*http.Transport).DisableCompression = true
|
||||
optimalConcurrentConnection := 32
|
||||
thisTransporter.(*http.Transport).MaxIdleConns = optimalConcurrentConnection * 2
|
||||
thisTransporter.(*http.Transport).MaxIdleConnsPerHost = optimalConcurrentConnection
|
||||
thisTransporter.(*http.Transport).IdleConnTimeout = 30 * time.Second
|
||||
thisTransporter.(*http.Transport).MaxConnsPerHost = optimalConcurrentConnection * 2
|
||||
thisTransporter.(*http.Transport).DisableCompression = true
|
||||
|
||||
if ignoreTLSVerification {
|
||||
//Ignore TLS certificate validation error
|
||||
@ -357,11 +358,6 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
|
||||
|
||||
//Custom header rewriter functions
|
||||
if res.Header.Get("Location") != "" {
|
||||
/*
|
||||
fmt.Println(">>> REQ", req)
|
||||
fmt.Println(">>> OUTR", outreq)
|
||||
fmt.Println(">>> RESP", res)
|
||||
*/
|
||||
locationRewrite := res.Header.Get("Location")
|
||||
originLocation := res.Header.Get("Location")
|
||||
res.Header.Set("zr-origin-location", originLocation)
|
||||
@ -369,12 +365,10 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
|
||||
if strings.HasPrefix(originLocation, "http://") || strings.HasPrefix(originLocation, "https://") {
|
||||
//Full path
|
||||
//Replace the forwarded target with expected Host
|
||||
lr, err := replaceLocationHost(locationRewrite, rrr.OriginalHost, req.TLS != nil)
|
||||
lr, err := replaceLocationHost(locationRewrite, rrr, req.TLS != nil)
|
||||
if err == nil {
|
||||
locationRewrite = lr
|
||||
}
|
||||
//locationRewrite = strings.ReplaceAll(locationRewrite, rrr.ProxyDomain, rrr.OriginalHost)
|
||||
//locationRewrite = strings.ReplaceAll(locationRewrite, domainWithoutPort, rrr.OriginalHost)
|
||||
} else if strings.HasPrefix(originLocation, "/") && rrr.PathPrefix != "" {
|
||||
//Back to the root of this proxy object
|
||||
//fmt.Println(rrr.ProxyDomain, rrr.OriginalHost)
|
||||
@ -387,6 +381,7 @@ func (p *ReverseProxy) ProxyHTTP(rw http.ResponseWriter, req *http.Request, rrr
|
||||
//Custom redirection to this rproxy relative path
|
||||
res.Header.Set("Location", locationRewrite)
|
||||
}
|
||||
|
||||
// Copy header from response to client.
|
||||
copyHeader(rw.Header(), res.Header)
|
||||
|
||||
|
49
src/mod/dynamicproxy/dpcore/dpcore_test.go
Normal file
49
src/mod/dynamicproxy/dpcore/dpcore_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
package dpcore_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
|
||||
)
|
||||
|
||||
func TestReplaceLocationHost(t *testing.T) {
|
||||
urlString := "http://private.com/test/newtarget/"
|
||||
rrr := &dpcore.ResponseRewriteRuleSet{
|
||||
OriginalHost: "test.example.com",
|
||||
ProxyDomain: "private.com/test",
|
||||
UseTLS: true,
|
||||
}
|
||||
useTLS := true
|
||||
|
||||
expectedResult := "https://test.example.com/newtarget/"
|
||||
|
||||
result, err := dpcore.ReplaceLocationHost(urlString, rrr, useTLS)
|
||||
if err != nil {
|
||||
t.Errorf("Error occurred: %v", err)
|
||||
}
|
||||
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected: %s, but got: %s", expectedResult, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceLocationHostRelative(t *testing.T) {
|
||||
urlString := "api/"
|
||||
rrr := &dpcore.ResponseRewriteRuleSet{
|
||||
OriginalHost: "test.example.com",
|
||||
ProxyDomain: "private.com/test",
|
||||
UseTLS: true,
|
||||
}
|
||||
useTLS := true
|
||||
|
||||
expectedResult := "https://test.example.com/api/"
|
||||
|
||||
result, err := dpcore.ReplaceLocationHost(urlString, rrr, useTLS)
|
||||
if err != nil {
|
||||
t.Errorf("Error occurred: %v", err)
|
||||
}
|
||||
|
||||
if result != expectedResult {
|
||||
t.Errorf("Expected: %s, but got: %s", expectedResult, result)
|
||||
}
|
||||
}
|
@ -2,20 +2,50 @@ package dpcore
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func replaceLocationHost(urlString string, newHost string, useTLS bool) (string, error) {
|
||||
// replaceLocationHost rewrite the backend server's location header to a new URL based on the given proxy rules
|
||||
// If you have issues with tailing slash, you can try to fix them here (and remember to PR :D )
|
||||
func replaceLocationHost(urlString string, rrr *ResponseRewriteRuleSet, useTLS bool) (string, error) {
|
||||
u, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
//Update the schemetic if the proxying target is http
|
||||
//but exposed as https to the internet via Zoraxy
|
||||
if useTLS {
|
||||
u.Scheme = "https"
|
||||
} else {
|
||||
u.Scheme = "http"
|
||||
}
|
||||
|
||||
u.Host = newHost
|
||||
u.Host = rrr.OriginalHost
|
||||
|
||||
if strings.Contains(rrr.ProxyDomain, "/") {
|
||||
//The proxy domain itself seems contain subpath.
|
||||
//Trim it off from Location header to prevent URL segment duplicate
|
||||
//E.g. Proxy config: blog.example.com -> example.com/blog
|
||||
//Location Header: /blog/post?id=1
|
||||
//Expected Location Header send to client:
|
||||
// blog.example.com/post?id=1 instead of blog.example.com/blog/post?id=1
|
||||
|
||||
ProxyDomainURL := "http://" + rrr.ProxyDomain
|
||||
if rrr.UseTLS {
|
||||
ProxyDomainURL = "https://" + rrr.ProxyDomain
|
||||
}
|
||||
ru, err := url.Parse(ProxyDomainURL)
|
||||
if err == nil {
|
||||
//Trim off the subpath
|
||||
u.Path = strings.TrimPrefix(u.Path, ru.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// Debug functions
|
||||
func ReplaceLocationHost(urlString string, rrr *ResponseRewriteRuleSet, useTLS bool) (string, error) {
|
||||
return replaceLocationHost(urlString, rrr, useTLS)
|
||||
}
|
||||
|
@ -95,6 +95,7 @@ func (h *ProxyHandler) subdomainRequest(w http.ResponseWriter, r *http.Request,
|
||||
UseTLS: target.RequireTLS,
|
||||
PathPrefix: "",
|
||||
})
|
||||
|
||||
var dnsError *net.DNSError
|
||||
if err != nil {
|
||||
if errors.As(err, &dnsError) {
|
||||
|
@ -28,13 +28,15 @@ func (t *RuleTable) HandleRedirect(w http.ResponseWriter, r *http.Request) int {
|
||||
rr := t.MatchRedirectRule(requestPath)
|
||||
if rr != nil {
|
||||
redirectTarget := rr.TargetURL
|
||||
//Always pad a / at the back of the target URL
|
||||
if redirectTarget[len(redirectTarget)-1:] != "/" {
|
||||
redirectTarget += "/"
|
||||
}
|
||||
|
||||
if rr.ForwardChildpath {
|
||||
//Remove the first / in the path
|
||||
redirectTarget += strings.TrimPrefix(r.URL.Path, "/")
|
||||
//Remove the first / in the path if the redirect target already have tailing slash
|
||||
if strings.HasSuffix(redirectTarget, "/") {
|
||||
redirectTarget += strings.TrimPrefix(r.URL.Path, "/")
|
||||
} else {
|
||||
redirectTarget += r.URL.Path
|
||||
}
|
||||
|
||||
if r.URL.RawQuery != "" {
|
||||
redirectTarget += "?" + r.URL.RawQuery
|
||||
}
|
||||
|
@ -13,10 +13,11 @@ import (
|
||||
*/
|
||||
|
||||
type RoutingRule struct {
|
||||
ID string
|
||||
MatchRule func(r *http.Request) bool
|
||||
RoutingHandler func(http.ResponseWriter, *http.Request)
|
||||
Enabled bool
|
||||
ID string //ID of the routing rule
|
||||
Enabled bool //If the routing rule enabled
|
||||
UseSystemAccessControl bool //Pass access control check to system white/black list, set this to false to bypass white/black list
|
||||
MatchRule func(r *http.Request) bool
|
||||
RoutingHandler func(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Router functions
|
||||
|
@ -13,8 +13,8 @@ import (
|
||||
)
|
||||
|
||||
func readAuthTokenAsAdmin() (string, error) {
|
||||
if utils.FileExists("./authtoken.secret") {
|
||||
authKey, err := os.ReadFile("./authtoken.secret")
|
||||
if utils.FileExists("./conf/authtoken.secret") {
|
||||
authKey, err := os.ReadFile("./conf/authtoken.secret")
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(authKey)), nil
|
||||
}
|
||||
|
@ -19,8 +19,8 @@ import (
|
||||
// Use admin permission to read auth token on Windows
|
||||
func readAuthTokenAsAdmin() (string, error) {
|
||||
//Check if the previous startup already extracted the authkey
|
||||
if utils.FileExists("./authtoken.secret") {
|
||||
authKey, err := os.ReadFile("./authtoken.secret")
|
||||
if utils.FileExists("./conf/authtoken.secret") {
|
||||
authKey, err := os.ReadFile("./conf/authtoken.secret")
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(authKey)), nil
|
||||
}
|
||||
@ -30,7 +30,7 @@ func readAuthTokenAsAdmin() (string, error) {
|
||||
exe := "cmd.exe"
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
output, _ := filepath.Abs(filepath.Join("./", "authtoken.secret"))
|
||||
output, _ := filepath.Abs(filepath.Join("./conf/", "authtoken.secret"))
|
||||
os.WriteFile(output, []byte(""), 0775)
|
||||
args := fmt.Sprintf("/C type \"C:\\ProgramData\\ZeroTier\\One\\authtoken.secret\" > \"" + output + "\"")
|
||||
|
||||
@ -49,13 +49,13 @@ func readAuthTokenAsAdmin() (string, error) {
|
||||
log.Println("Please click agree to allow access to ZeroTier authtoken from ProgramData")
|
||||
retry := 0
|
||||
time.Sleep(3 * time.Second)
|
||||
for !utils.FileExists("./authtoken.secret") && retry < 10 {
|
||||
for !utils.FileExists("./conf/authtoken.secret") && retry < 10 {
|
||||
time.Sleep(3 * time.Second)
|
||||
log.Println("Waiting for ZeroTier authtoken extraction...")
|
||||
retry++
|
||||
}
|
||||
|
||||
authKey, err := os.ReadFile("./authtoken.secret")
|
||||
authKey, err := os.ReadFile("./conf/authtoken.secret")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
@ -3,9 +3,11 @@ package netutils
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/likexian/whois"
|
||||
"imuslab.com/zoraxy/mod/utils"
|
||||
)
|
||||
|
||||
@ -46,6 +48,50 @@ func TraceRoute(targetIpOrDomain string, maxHops int) ([]string, error) {
|
||||
return traceroute(targetIpOrDomain, maxHops)
|
||||
}
|
||||
|
||||
func HandleWhois(w http.ResponseWriter, r *http.Request) {
|
||||
targetIpOrDomain, err := utils.GetPara(r, "target")
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, "invalid target (domain or ip) address given")
|
||||
return
|
||||
}
|
||||
|
||||
raw, _ := utils.GetPara(r, "raw")
|
||||
|
||||
result, err := whois.Whois(targetIpOrDomain)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if raw == "true" {
|
||||
utils.SendTextResponse(w, result)
|
||||
} else {
|
||||
if isDomainName(targetIpOrDomain) {
|
||||
//Is Domain
|
||||
parsedOutput, err := ParseWHOISResponse(result)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(parsedOutput)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
} else {
|
||||
//Is IP
|
||||
parsedOutput, err := ParseWhoisIpData(result)
|
||||
if err != nil {
|
||||
utils.SendErrorResponse(w, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
js, _ := json.Marshal(parsedOutput)
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func HandlePing(w http.ResponseWriter, r *http.Request) {
|
||||
targetIpOrDomain, err := utils.GetPara(r, "target")
|
||||
if err != nil {
|
||||
@ -53,13 +99,44 @@ func HandlePing(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
results := []string{}
|
||||
type MixedPingResults struct {
|
||||
ICMP []string
|
||||
TCP []string
|
||||
UDP []string
|
||||
}
|
||||
|
||||
results := MixedPingResults{
|
||||
ICMP: []string{},
|
||||
TCP: []string{},
|
||||
UDP: []string{},
|
||||
}
|
||||
|
||||
//Ping ICMP
|
||||
for i := 0; i < 4; i++ {
|
||||
realIP, pingTime, ttl, err := PingIP(targetIpOrDomain)
|
||||
if err != nil {
|
||||
results = append(results, "Reply from "+realIP+": "+err.Error())
|
||||
results.ICMP = append(results.ICMP, "Reply from "+realIP+": "+err.Error())
|
||||
} else {
|
||||
results = append(results, fmt.Sprintf("Reply from %s: Time=%dms TTL=%d", realIP, pingTime.Milliseconds(), ttl))
|
||||
results.ICMP = append(results.ICMP, fmt.Sprintf("Reply from %s: Time=%dms TTL=%d", realIP, pingTime.Milliseconds(), ttl))
|
||||
}
|
||||
}
|
||||
|
||||
//Ping TCP
|
||||
for i := 0; i < 4; i++ {
|
||||
pingTime, err := TCPPing(targetIpOrDomain)
|
||||
if err != nil {
|
||||
results.TCP = append(results.TCP, "Reply from "+resolveIpFromDomain(targetIpOrDomain)+": "+err.Error())
|
||||
} else {
|
||||
results.TCP = append(results.TCP, fmt.Sprintf("Reply from %s: Time=%dms", resolveIpFromDomain(targetIpOrDomain), pingTime.Milliseconds()))
|
||||
}
|
||||
}
|
||||
//Ping UDP
|
||||
for i := 0; i < 4; i++ {
|
||||
pingTime, err := UDPPing(targetIpOrDomain)
|
||||
if err != nil {
|
||||
results.UDP = append(results.UDP, "Reply from "+resolveIpFromDomain(targetIpOrDomain)+": "+err.Error())
|
||||
} else {
|
||||
results.UDP = append(results.UDP, fmt.Sprintf("Reply from %s: Time=%dms", resolveIpFromDomain(targetIpOrDomain), pingTime.Milliseconds()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,3 +144,16 @@ func HandlePing(w http.ResponseWriter, r *http.Request) {
|
||||
utils.SendJSONResponse(w, string(js))
|
||||
|
||||
}
|
||||
|
||||
func resolveIpFromDomain(targetIpOrDomain string) string {
|
||||
//Resolve target ip address
|
||||
targetIpAddrString := ""
|
||||
ipAddr, err := net.ResolveIPAddr("ip", targetIpOrDomain)
|
||||
if err != nil {
|
||||
targetIpAddrString = targetIpOrDomain
|
||||
} else {
|
||||
targetIpAddrString = ipAddr.IP.String()
|
||||
}
|
||||
|
||||
return targetIpAddrString
|
||||
}
|
||||
|
@ -6,6 +6,39 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TCP ping
|
||||
func TCPPing(ipOrDomain string) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
|
||||
conn, err := net.DialTimeout("tcp", ipOrDomain+":80", 3*time.Second)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to establish TCP connection: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
pingTime := elapsed.Round(time.Millisecond)
|
||||
|
||||
return pingTime, nil
|
||||
}
|
||||
|
||||
// UDP Ping
|
||||
func UDPPing(ipOrDomain string) (time.Duration, error) {
|
||||
start := time.Now()
|
||||
|
||||
conn, err := net.DialTimeout("udp", ipOrDomain+":80", 3*time.Second)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to establish UDP connection: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
pingTime := elapsed.Round(time.Millisecond)
|
||||
|
||||
return pingTime, nil
|
||||
}
|
||||
|
||||
// Traditional ICMP ping
|
||||
func PingIP(ipOrDomain string) (string, time.Duration, int, error) {
|
||||
ipAddr, err := net.ResolveIPAddr("ip", ipOrDomain)
|
||||
if err != nil {
|
||||
|
199
src/mod/netutils/whois.go
Normal file
199
src/mod/netutils/whois.go
Normal file
@ -0,0 +1,199 @@
|
||||
package netutils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WHOISResult struct {
|
||||
DomainName string `json:"domainName"`
|
||||
RegistryDomainID string `json:"registryDomainID"`
|
||||
Registrar string `json:"registrar"`
|
||||
UpdatedDate time.Time `json:"updatedDate"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
ExpiryDate time.Time `json:"expiryDate"`
|
||||
RegistrantID string `json:"registrantID"`
|
||||
RegistrantName string `json:"registrantName"`
|
||||
RegistrantEmail string `json:"registrantEmail"`
|
||||
AdminID string `json:"adminID"`
|
||||
AdminName string `json:"adminName"`
|
||||
AdminEmail string `json:"adminEmail"`
|
||||
TechID string `json:"techID"`
|
||||
TechName string `json:"techName"`
|
||||
TechEmail string `json:"techEmail"`
|
||||
NameServers []string `json:"nameServers"`
|
||||
DNSSEC string `json:"dnssec"`
|
||||
}
|
||||
|
||||
func ParseWHOISResponse(response string) (WHOISResult, error) {
|
||||
result := WHOISResult{}
|
||||
|
||||
lines := strings.Split(response, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Domain Name:") {
|
||||
result.DomainName = strings.TrimSpace(strings.TrimPrefix(line, "Domain Name:"))
|
||||
} else if strings.HasPrefix(line, "Registry Domain ID:") {
|
||||
result.RegistryDomainID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Domain ID:"))
|
||||
} else if strings.HasPrefix(line, "Registrar:") {
|
||||
result.Registrar = strings.TrimSpace(strings.TrimPrefix(line, "Registrar:"))
|
||||
} else if strings.HasPrefix(line, "Updated Date:") {
|
||||
dateStr := strings.TrimSpace(strings.TrimPrefix(line, "Updated Date:"))
|
||||
updatedDate, err := time.Parse("2006-01-02T15:04:05Z", dateStr)
|
||||
if err == nil {
|
||||
result.UpdatedDate = updatedDate
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Creation Date:") {
|
||||
dateStr := strings.TrimSpace(strings.TrimPrefix(line, "Creation Date:"))
|
||||
creationDate, err := time.Parse("2006-01-02T15:04:05Z", dateStr)
|
||||
if err == nil {
|
||||
result.CreationDate = creationDate
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Registry Expiry Date:") {
|
||||
dateStr := strings.TrimSpace(strings.TrimPrefix(line, "Registry Expiry Date:"))
|
||||
expiryDate, err := time.Parse("2006-01-02T15:04:05Z", dateStr)
|
||||
if err == nil {
|
||||
result.ExpiryDate = expiryDate
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Registry Registrant ID:") {
|
||||
result.RegistrantID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Registrant ID:"))
|
||||
} else if strings.HasPrefix(line, "Registrant Name:") {
|
||||
result.RegistrantName = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Name:"))
|
||||
} else if strings.HasPrefix(line, "Registrant Email:") {
|
||||
result.RegistrantEmail = strings.TrimSpace(strings.TrimPrefix(line, "Registrant Email:"))
|
||||
} else if strings.HasPrefix(line, "Registry Admin ID:") {
|
||||
result.AdminID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Admin ID:"))
|
||||
} else if strings.HasPrefix(line, "Admin Name:") {
|
||||
result.AdminName = strings.TrimSpace(strings.TrimPrefix(line, "Admin Name:"))
|
||||
} else if strings.HasPrefix(line, "Admin Email:") {
|
||||
result.AdminEmail = strings.TrimSpace(strings.TrimPrefix(line, "Admin Email:"))
|
||||
} else if strings.HasPrefix(line, "Registry Tech ID:") {
|
||||
result.TechID = strings.TrimSpace(strings.TrimPrefix(line, "Registry Tech ID:"))
|
||||
} else if strings.HasPrefix(line, "Tech Name:") {
|
||||
result.TechName = strings.TrimSpace(strings.TrimPrefix(line, "Tech Name:"))
|
||||
} else if strings.HasPrefix(line, "Tech Email:") {
|
||||
result.TechEmail = strings.TrimSpace(strings.TrimPrefix(line, "Tech Email:"))
|
||||
} else if strings.HasPrefix(line, "Name Server:") {
|
||||
ns := strings.TrimSpace(strings.TrimPrefix(line, "Name Server:"))
|
||||
result.NameServers = append(result.NameServers, ns)
|
||||
} else if strings.HasPrefix(line, "DNSSEC:") {
|
||||
result.DNSSEC = strings.TrimSpace(strings.TrimPrefix(line, "DNSSEC:"))
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type WhoisIpLookupEntry struct {
|
||||
NetRange string
|
||||
CIDR string
|
||||
NetName string
|
||||
NetHandle string
|
||||
Parent string
|
||||
NetType string
|
||||
OriginAS string
|
||||
Organization Organization
|
||||
RegDate time.Time
|
||||
Updated time.Time
|
||||
Ref string
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
OrgName string
|
||||
OrgId string
|
||||
Address string
|
||||
City string
|
||||
StateProv string
|
||||
PostalCode string
|
||||
Country string
|
||||
/*
|
||||
RegDate time.Time
|
||||
Updated time.Time
|
||||
OrgTechHandle string
|
||||
OrgTechName string
|
||||
OrgTechPhone string
|
||||
OrgTechEmail string
|
||||
OrgAbuseHandle string
|
||||
OrgAbuseName string
|
||||
OrgAbusePhone string
|
||||
OrgAbuseEmail string
|
||||
OrgRoutingHandle string
|
||||
OrgRoutingName string
|
||||
OrgRoutingPhone string
|
||||
OrgRoutingEmail string
|
||||
*/
|
||||
}
|
||||
|
||||
func ParseWhoisIpData(data string) (WhoisIpLookupEntry, error) {
|
||||
var entry WhoisIpLookupEntry = WhoisIpLookupEntry{}
|
||||
var org Organization = Organization{}
|
||||
|
||||
lines := strings.Split(data, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "NetRange:") {
|
||||
entry.NetRange = strings.TrimSpace(strings.TrimPrefix(line, "NetRange:"))
|
||||
} else if strings.HasPrefix(line, "CIDR:") {
|
||||
entry.CIDR = strings.TrimSpace(strings.TrimPrefix(line, "CIDR:"))
|
||||
} else if strings.HasPrefix(line, "NetName:") {
|
||||
entry.NetName = strings.TrimSpace(strings.TrimPrefix(line, "NetName:"))
|
||||
} else if strings.HasPrefix(line, "NetHandle:") {
|
||||
entry.NetHandle = strings.TrimSpace(strings.TrimPrefix(line, "NetHandle:"))
|
||||
} else if strings.HasPrefix(line, "Parent:") {
|
||||
entry.Parent = strings.TrimSpace(strings.TrimPrefix(line, "Parent:"))
|
||||
} else if strings.HasPrefix(line, "NetType:") {
|
||||
entry.NetType = strings.TrimSpace(strings.TrimPrefix(line, "NetType:"))
|
||||
} else if strings.HasPrefix(line, "OriginAS:") {
|
||||
entry.OriginAS = strings.TrimSpace(strings.TrimPrefix(line, "OriginAS:"))
|
||||
} else if strings.HasPrefix(line, "Organization:") {
|
||||
org.OrgName = strings.TrimSpace(strings.TrimPrefix(line, "Organization:"))
|
||||
} else if strings.HasPrefix(line, "OrgId:") {
|
||||
org.OrgId = strings.TrimSpace(strings.TrimPrefix(line, "OrgId:"))
|
||||
} else if strings.HasPrefix(line, "Address:") {
|
||||
org.Address = strings.TrimSpace(strings.TrimPrefix(line, "Address:"))
|
||||
} else if strings.HasPrefix(line, "City:") {
|
||||
org.City = strings.TrimSpace(strings.TrimPrefix(line, "City:"))
|
||||
} else if strings.HasPrefix(line, "StateProv:") {
|
||||
org.StateProv = strings.TrimSpace(strings.TrimPrefix(line, "StateProv:"))
|
||||
} else if strings.HasPrefix(line, "PostalCode:") {
|
||||
org.PostalCode = strings.TrimSpace(strings.TrimPrefix(line, "PostalCode:"))
|
||||
} else if strings.HasPrefix(line, "Country:") {
|
||||
org.Country = strings.TrimSpace(strings.TrimPrefix(line, "Country:"))
|
||||
} else if strings.HasPrefix(line, "RegDate:") {
|
||||
entry.RegDate, _ = parseDate(strings.TrimSpace(strings.TrimPrefix(line, "RegDate:")))
|
||||
} else if strings.HasPrefix(line, "Updated:") {
|
||||
entry.Updated, _ = parseDate(strings.TrimSpace(strings.TrimPrefix(line, "Updated:")))
|
||||
} else if strings.HasPrefix(line, "Ref:") {
|
||||
entry.Ref = strings.TrimSpace(strings.TrimPrefix(line, "Ref:"))
|
||||
}
|
||||
}
|
||||
|
||||
entry.Organization = org
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func parseDate(dateStr string) (time.Time, error) {
|
||||
dateLayout := "2006-01-02"
|
||||
date, err := time.Parse(dateLayout, strings.TrimSpace(dateStr))
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return date, nil
|
||||
}
|
||||
|
||||
func isDomainName(input string) bool {
|
||||
ip := net.ParseIP(input)
|
||||
if ip != nil {
|
||||
// Check if it's IPv4 or IPv6
|
||||
if ip.To4() != nil {
|
||||
return false
|
||||
} else if ip.To16() != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
_, err := net.LookupHost(input)
|
||||
return err == nil
|
||||
}
|
@ -12,15 +12,14 @@ import (
|
||||
)
|
||||
|
||||
/*
|
||||
Pathblock.go
|
||||
Pathrules.go
|
||||
|
||||
This script block off some of the specific pathname in access
|
||||
For example, this module can help you block request for a particular
|
||||
apache directory or functional endpoints like /.well-known/ when you
|
||||
are not using it
|
||||
This script handle advance path settings and rules on particular
|
||||
paths of the incoming requests
|
||||
*/
|
||||
|
||||
type Options struct {
|
||||
Enabled bool //If the pathrule is enabled.
|
||||
ConfigFolder string //The folder to store the path blocking config files
|
||||
}
|
||||
|
||||
@ -41,7 +40,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// Create a new path blocker handler
|
||||
func NewPathBlocker(options *Options) *Handler {
|
||||
func NewPathRuleHandler(options *Options) *Handler {
|
||||
//Create folder if not exists
|
||||
if !utils.FileExists(options.ConfigFolder) {
|
||||
os.Mkdir(options.ConfigFolder, 0775)
|
||||
|
@ -73,7 +73,7 @@ func ReverseProxtInit() {
|
||||
dynamicProxyRouter = dprouter
|
||||
|
||||
//Load all conf from files
|
||||
confs, _ := filepath.Glob("./conf/*.config")
|
||||
confs, _ := filepath.Glob("./conf/proxy/*.config")
|
||||
for _, conf := range confs {
|
||||
record, err := LoadReverseProxyConfig(conf)
|
||||
if err != nil {
|
||||
|
30
src/start.go
30
src/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"
|
||||
@ -48,8 +49,9 @@ func startupSequence() {
|
||||
//Create tables for the database
|
||||
sysdb.NewTable("settings")
|
||||
|
||||
//Create tmp folder
|
||||
//Create tmp folder and conf folder
|
||||
os.MkdirAll("./tmp", 0775)
|
||||
os.MkdirAll("./conf/proxy/", 0775)
|
||||
|
||||
//Create an auth agent
|
||||
sessionKey, err := auth.GetSessionKey(sysdb)
|
||||
@ -62,13 +64,13 @@ func startupSequence() {
|
||||
})
|
||||
|
||||
//Create a TLS certificate manager
|
||||
tlsCertManager, err = tlscert.NewManager("./certs", development)
|
||||
tlsCertManager, err = tlscert.NewManager("./conf/certs", development)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
//Create a redirection rule table
|
||||
redirectTable, err = redirection.NewRuleTable("./rules/redirect")
|
||||
redirectTable, err = redirection.NewRuleTable("./conf/redirect")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@ -95,14 +97,15 @@ func startupSequence() {
|
||||
}
|
||||
|
||||
/*
|
||||
Path Blocker
|
||||
Path Rules
|
||||
|
||||
This section of starutp script start the pathblocker
|
||||
from file.
|
||||
This section of starutp script start the path rules where
|
||||
user can define their own routing logics
|
||||
*/
|
||||
|
||||
pathRuleHandler = pathrule.NewPathBlocker(&pathrule.Options{
|
||||
ConfigFolder: "./rules/pathrules",
|
||||
pathRuleHandler = pathrule.NewPathRuleHandler(&pathrule.Options{
|
||||
Enabled: false,
|
||||
ConfigFolder: "./conf/rules/pathrules",
|
||||
})
|
||||
|
||||
/*
|
||||
@ -188,6 +191,17 @@ func startupSequence() {
|
||||
|
||||
//Create an analytic loader
|
||||
AnalyticLoader = analytic.NewDataLoader(sysdb, statisticCollector)
|
||||
|
||||
/*
|
||||
ACME API
|
||||
|
||||
Obtaining certificates from ACME Server
|
||||
*/
|
||||
acmeHandler = initACME()
|
||||
acmeAutoRenewer, err = acme.NewAutoRenewer("./conf/acme_conf.json", "./conf/certs/", int64(*acmeAutoRenewInterval), acmeHandler)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// This sequence start after everything is initialized
|
||||
|
@ -67,6 +67,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="ui basic button" onclick="initManagedDomainCertificateList();"><i class="green refresh icon"></i> Refresh List</button>
|
||||
<button class="ui basic button" onclick="openACMEManager();"><i class="yellow refresh icon"></i> Auto Renew (ACME) Settings</button>
|
||||
</div>
|
||||
<div class="ui message">
|
||||
<h4><i class="info circle icon"></i> Sub-domain Certificates</h4>
|
||||
@ -106,11 +107,14 @@
|
||||
msgbox(data.error, false, 5000);
|
||||
}else{
|
||||
$("#certifiedDomainList").html("");
|
||||
data.sort((a,b) => {
|
||||
return a.Domain > b.Domain
|
||||
});
|
||||
data.forEach(entry => {
|
||||
$("#certifiedDomainList").append(`<tr>
|
||||
<td>${entry.Domain}</td>
|
||||
<td>${entry.LastModifiedDate}</td>
|
||||
<td>${entry.ExpireDate}</td>
|
||||
<td>${entry.ExpireDate} (${entry.RemainingDays} days left)</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>
|
||||
</tr>`);
|
||||
});
|
||||
@ -125,6 +129,10 @@
|
||||
}
|
||||
initManagedDomainCertificateList();
|
||||
|
||||
function openACMEManager(){
|
||||
showSideWrapper('snippet/acme.html');
|
||||
}
|
||||
|
||||
function handleDomainUploadByKeypress(){
|
||||
handleDomainKeysUpload(function(){
|
||||
$("#certUploadingDomain").text($("#certdomain").val().trim());
|
||||
|
@ -45,8 +45,17 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class=""></div>
|
||||
|
||||
<div class="ui divider"></div>
|
||||
<!-- Whois-->
|
||||
<h2>Whois</h2>
|
||||
<p>Check the owner and registration information of a given domain</p>
|
||||
<div class="ui icon input">
|
||||
<input id="whoisdomain" type="text" onkeypress="if(event.keyCode === 13) { performWhoisLookup(); }" placeholder="Domain or IP">
|
||||
<i onclick="performWhoisLookup();" class="circular search link icon"></i>
|
||||
</div><br>
|
||||
<small>Lookup might take a few minutes to complete</small>
|
||||
<br>
|
||||
<div id="whois_table"></div>
|
||||
</div>
|
||||
|
||||
<div class="ui bottom attached tab segment nettoolstab" data-tab="tab2">
|
||||
@ -485,10 +494,70 @@ function ping(){
|
||||
$("#traceroute_results").val("");
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
$("#traceroute_results").val(data.join("\n"));
|
||||
$("#traceroute_results").val(`--------- ICMP Ping -------------
|
||||
${data.ICMP.join("\n")}\n
|
||||
---------- TCP Ping -------------
|
||||
${data.TCP.join("\n")}\n
|
||||
---------- UDP Ping -------------
|
||||
${data.UDP.join("\n")}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function performWhoisLookup(){
|
||||
let whoisDomain = $("#whoisdomain").val().trim();
|
||||
$("#whoisdomain").parent().addClass("disabled");
|
||||
$("#whoisdomain").parent().css({
|
||||
"cursor": "wait"
|
||||
});
|
||||
$.get("/api/tools/whois?target=" + whoisDomain, function(data){
|
||||
$("#whoisdomain").parent().removeClass("disabled");
|
||||
$("#whoisdomain").parent().css({
|
||||
"cursor": "auto"
|
||||
});
|
||||
if (data.error != undefined){
|
||||
msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
renderWhoisDomainTable(data);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function renderWhoisDomainTable(jsonData) {
|
||||
|
||||
function formatDate(dateString) {
|
||||
var date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
var table = $('<table>').addClass('ui definition table');
|
||||
|
||||
// Create table body
|
||||
var body = $('<tbody>');
|
||||
for (var key in jsonData) {
|
||||
var value = jsonData[key];
|
||||
var row = $('<tr>');
|
||||
row.append($('<td>').text(key));
|
||||
if (key.endsWith('Date')) {
|
||||
row.append($('<td>').text(formatDate(value)));
|
||||
} else if (Array.isArray(value)) {
|
||||
row.append($('<td>').text(value.join(', ')));
|
||||
}else if (typeof(value) == "object"){
|
||||
row.append($('<td>').text(JSON.stringify(value)));
|
||||
} else {
|
||||
row.append($('<td>').text(value));
|
||||
}
|
||||
body.append(row);
|
||||
}
|
||||
|
||||
// Append the table body to the table
|
||||
table.append(body);
|
||||
|
||||
// Append the table to the target element
|
||||
$('#whois_table').empty().append(table);
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
@ -39,7 +39,7 @@
|
||||
<div class="field">
|
||||
<label>Destination URL (To)</label>
|
||||
<input type="text" name="destination-url" placeholder="Destination URL">
|
||||
<small><i class="ui circle info icon"></i> The target URL request being redirected to, e.g. dest.example.com/mysite</small>
|
||||
<small><i class="ui circle info icon"></i> The target URL request being redirected to, e.g. dest.example.com/mysite/ or dest.example.com/script.php, <b>sometime you might need to add tailing slash (/) to your URL depending on your use cases</b></small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
@ -72,7 +72,7 @@
|
||||
<i class="ui green checkmark icon"></i> Redirection Rules Added
|
||||
</div>
|
||||
<br><br>
|
||||
<!--
|
||||
|
||||
<div class="advancezone ui basic segment">
|
||||
<div class="ui accordion advanceSettings">
|
||||
<div class="title">
|
||||
@ -85,7 +85,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -43,10 +43,17 @@
|
||||
tlsIcon = `<i class="green lock icon" title="TLS Mode"></i>`;
|
||||
}
|
||||
|
||||
let tlsVerificationField = "";
|
||||
if (subd.RequireTLS){
|
||||
tlsVerificationField = !subd.SkipCertValidations?`<i class="ui green check icon"></i>`:`<i class="ui yellow exclamation circle icon" title="TLS/SSL Verification will be skipped on this host"></i>`
|
||||
}else{
|
||||
tlsVerificationField = "N/A"
|
||||
}
|
||||
|
||||
$("#subdList").append(`<tr eptuuid="${subd.RootOrMatchingDomain}" payload="${subdData}" class="subdEntry">
|
||||
<td data-label="" editable="false"><a href="//${subd.RootOrMatchingDomain}" target="_blank">${subd.RootOrMatchingDomain}</a></td>
|
||||
<td data-label="" editable="true" datatype="domain">${subd.Domain} ${tlsIcon}</td>
|
||||
<td data-label="" editable="true" datatype="skipver">${!subd.SkipCertValidations?`<i class="ui green check icon"></i>`:`<i class="ui yellow exclamation circle icon" title="TLS/SSL Verification will be skipped on this host"></i>`}</td>
|
||||
<td data-label="" editable="true" datatype="skipver">${tlsVerificationField}</td>
|
||||
<td data-label="" editable="true" datatype="basicauth">${subd.RequireBasicAuth?`<i class="ui green check icon"></i>`:`<i class="ui grey remove icon"></i>`}</td>
|
||||
<td class="center aligned" editable="true" datatype="action" data-label="">
|
||||
<button class="ui circular mini basic icon button editBtn" onclick='editEndpoint("subd","${subd.RootOrMatchingDomain}")'><i class="edit icon"></i></button>
|
||||
|
@ -116,7 +116,11 @@
|
||||
</div>
|
||||
<p>Results: <div id="ipRangeOutput">N/A</div></p>
|
||||
</div>
|
||||
|
||||
<!-- Config Tools -->
|
||||
<div class="ui divider"></div>
|
||||
<h3>System Backup & Restore</h3>
|
||||
<p>Options related to system backup, migrate and restore.</p>
|
||||
<button class="ui basic button" onclick="showSideWrapper('snippet/configTools.html');">Open Config Tools</button>
|
||||
<!-- System Information -->
|
||||
<div class="ui divider"></div>
|
||||
<div id="zoraxyinfo">
|
||||
|
@ -44,10 +44,18 @@
|
||||
if (vdir.RequireTLS){
|
||||
tlsIcon = `<i class="green lock icon" title="TLS Mode"></i>`;
|
||||
}
|
||||
|
||||
let tlsVerificationField = "";
|
||||
if (vdir.RequireTLS){
|
||||
tlsVerificationField = !vdir.SkipCertValidations?`<i class="ui green check icon"></i>`:`<i class="ui yellow exclamation circle icon" title="TLS/SSL Verification will be skipped on this host"></i>`
|
||||
}else{
|
||||
tlsVerificationField = "N/A"
|
||||
}
|
||||
|
||||
$("#vdirList").append(`<tr eptuuid="${vdir.RootOrMatchingDomain}" payload="${vdirData}" class="vdirEntry">
|
||||
<td data-label="" editable="false">${vdir.RootOrMatchingDomain}</td>
|
||||
<td data-label="" editable="true" datatype="domain">${vdir.Domain} ${tlsIcon}</td>
|
||||
<td data-label="" editable="true" datatype="skipver">${!vdir.SkipCertValidations?`<i class="ui green check icon"></i>`:`<i class="ui yellow exclamation circle icon" title="TLS/SSL Verification will be skipped on this host"></i>`}</td>
|
||||
<td data-label="" editable="true" datatype="skipver">${tlsVerificationField}</td>
|
||||
<td data-label="" editable="true" datatype="basicauth">${vdir.RequireBasicAuth?`<i class="ui green check icon"></i>`:`<i class="ui grey remove icon"></i>`}</td>
|
||||
<td class="center aligned" editable="true" datatype="action" data-label="">
|
||||
<button class="ui circular mini basic icon button editBtn" onclick='editEndpoint("vdir","${vdir.RootOrMatchingDomain}")'><i class="edit icon"></i></button>
|
||||
|
@ -364,6 +364,7 @@
|
||||
$(".sideWrapper").show();
|
||||
$(".sideWrapper .fadingBackground").fadeIn("fast");
|
||||
$(".sideWrapper .content").transition('slide left in', 300);
|
||||
$("body").css("overflow", "hidden");
|
||||
}
|
||||
|
||||
function hideSideWrapper(discardFrameContent = false){
|
||||
@ -378,6 +379,7 @@
|
||||
$(".sideWrapper").hide();
|
||||
});
|
||||
});
|
||||
$("body").css("overflow", "auto");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
@ -179,7 +179,7 @@ body{
|
||||
}
|
||||
|
||||
.sideWrapper iframe{
|
||||
height: 100%;
|
||||
height: calc(100% - 55px);
|
||||
width: 100%;
|
||||
border: 0px solid transparent;
|
||||
}
|
||||
|
475
src/web/snippet/acme.html
Normal file
475
src/web/snippet/acme.html
Normal file
@ -0,0 +1,475 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Notes: This should be open in its original path-->
|
||||
<link rel="stylesheet" href="../script/semantic/semantic.min.css">
|
||||
<script src="../script/jquery-3.6.0.min.js"></script>
|
||||
<script src="../script/semantic/semantic.min.js"></script>
|
||||
<style>
|
||||
.disabled.table{
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
|
||||
}
|
||||
|
||||
.expiredDomain{
|
||||
color: rgb(238, 31, 31);
|
||||
}
|
||||
|
||||
.validDomain{
|
||||
color: rgb(49, 192, 113);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<br>
|
||||
<div class="ui container">
|
||||
<div class="ui header">
|
||||
<div class="content">
|
||||
Certificates Auto Renew Settings
|
||||
<div class="sub header">Fetch and renew your certificates with Automated Certificate Management Environment (ACME) protocol</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui basic segment">
|
||||
<p style="float: right; color: #21ba45; display:none;" id="enableToggleSucc"><i class="green checkmark icon"></i> Setting Updated</p>
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" id="enableCertAutoRenew">
|
||||
<label>Enable Certificate Auto Renew</label>
|
||||
</div>
|
||||
<br>
|
||||
<h3>ACME Email</h3>
|
||||
<p>Email is required by many CAs for renewing via ACME protocol</p>
|
||||
<div class="ui fluid action input">
|
||||
<input id="caRegisterEmail" type="text" placeholder="webmaster@example.com">
|
||||
<button class="ui icon basic button" onclick="saveEmailToConfig(this);">
|
||||
<i class="blue save icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
<small>If you don't want to share your private email address, you can also fill in an email address that point to a mailbox not exists on your domain.</small>
|
||||
</div>
|
||||
<div class="ui basic segment" style="background-color: #f7f7f7; border-radius: 1em;">
|
||||
<div class="ui accordion advanceSettings">
|
||||
<div class="title">
|
||||
<i class="dropdown icon"></i>
|
||||
Advance Renew Policy
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Renew all certificates with ACME supported CAs</p>
|
||||
<div class="ui toggle checkbox">
|
||||
<input type="checkbox" id="renewAllSupported" onchange="setAutoRenewIfCASupportMode(this.checked);">
|
||||
<label>Renew All Certs</label>
|
||||
</div><br>
|
||||
<button id="renewNowBtn" onclick="renewNow();" 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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain Name</th>
|
||||
<th>Match Rule</th>
|
||||
<th>Auto-Renew</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="domainTableBody"></tbody>
|
||||
</table>
|
||||
<small><i class="ui red info circle icon"></i> Domain in red are expired</small><br>
|
||||
<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.
|
||||
</div>
|
||||
<button class="ui basic right floated button" onclick="saveAutoRenewPolicy();"><i class="blue save icon"></i> Save Changes</button>
|
||||
<button id="renewSelectedButton" onclick="renewNow();" class="ui basic right floated disabled button"><i class="yellow refresh icon"></i> Renew Selected</button>
|
||||
<br><br>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<h3>Manual Renew</h3>
|
||||
<p>Pick a certificate below to force renew</p>
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<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 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" id="ca">
|
||||
<input type="hidden" name="ca">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="default text">Let's Encrypt</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button id="obtainButton" class="ui basic button" type="submit"><i class="yellow refresh icon"></i> Renew Certificate</button>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<small>First time setting up HTTPS?<br>Try out our <a href="../tools/https.html" target="_blank">wizard</a></small>
|
||||
<button class="ui basic button" style="float: right;" onclick="parent.hideSideWrapper();"><i class="remove icon"></i> Cancel</button>
|
||||
<br><br><br><br>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let expiredDomains = [];
|
||||
let enableTrigerOnChangeEvent = true;
|
||||
$(".accordion").accordion();
|
||||
$(".dropdown").dropdown();
|
||||
|
||||
function setAutoRenewIfCASupportMode(useAutoMode = true){
|
||||
if (useAutoMode){
|
||||
$("#domainCertFileTable").addClass("disabled");
|
||||
$("#renewNowBtn").removeClass("disabled");
|
||||
$("#renewSelectedButton").addClass("disabled");
|
||||
}else{
|
||||
$("#domainCertFileTable").removeClass("disabled");
|
||||
$("#renewNowBtn").addClass("disabled");
|
||||
$("#renewSelectedButton").removeClass("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
function initRenewerConfigFromFile(){
|
||||
//Set the renew switch state
|
||||
$.get("/api/acme/autoRenew/enable", function(data){
|
||||
if (data == true){
|
||||
$("#enableCertAutoRenew").parent().checkbox("set checked");
|
||||
}
|
||||
|
||||
$("#enableCertAutoRenew").on("change", function(){
|
||||
if (!enableTrigerOnChangeEvent){
|
||||
return;
|
||||
}
|
||||
toggleAutoRenew();
|
||||
})
|
||||
});
|
||||
|
||||
//Load the email from server side
|
||||
$.get("/api/acme/autoRenew/email", function(data){
|
||||
if (data != "" && data != undefined && data != null){
|
||||
$("#caRegisterEmail").val(data);
|
||||
}
|
||||
});
|
||||
|
||||
//Load the domain selection options
|
||||
$.get("/api/acme/autoRenew/renewPolicy", function(data){
|
||||
if (data == true){
|
||||
$("#renewAllSupported").parent().checkbox("set checked");
|
||||
}else{
|
||||
$("#renewAllSupported").parent().checkbox("set unchecked");
|
||||
}
|
||||
});
|
||||
}
|
||||
initRenewerConfigFromFile();
|
||||
|
||||
function saveEmailToConfig(btn){
|
||||
$.ajax({
|
||||
url: "/api/acme/autoRenew/email",
|
||||
data: {set: $("#caRegisterEmail").val()},
|
||||
success: function(data){
|
||||
if (data.error != undefined){
|
||||
parent.msgbox(data.error, false, 5000);
|
||||
}else{
|
||||
parent.msgbox("Email updated");
|
||||
$(btn).html(`<i class="green check icon"></i>`);
|
||||
$(btn).addClass("disabled");
|
||||
setTimeout(function(){
|
||||
$(btn).html(`<i class="blue save icon"></i>`);
|
||||
$(btn).removeClass("disabled");
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAutoRenew(){
|
||||
var enabled = $("#enableCertAutoRenew").parent().checkbox("is checked");
|
||||
$.post("/api/acme/autoRenew/enable?enable=" + enabled, function(data){
|
||||
if (data.error){
|
||||
parent.msgbox(data.error, false, 5000);
|
||||
if (enabled){
|
||||
enableTrigerOnChangeEvent = false;
|
||||
$("#enableCertAutoRenew").parent().checkbox("set unchecked");
|
||||
enableTrigerOnChangeEvent = true;
|
||||
}
|
||||
}else{
|
||||
$("#enableToggleSucc").stop().finish().fadeIn("fast").delay(3000).fadeOut("fast");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//Render the domains table that exists in this zoraxy host
|
||||
function renderDomainTable(domainFileList) {
|
||||
// Get the table body element
|
||||
var tableBody = $('#domainTableBody');
|
||||
|
||||
// Clear the table body
|
||||
tableBody.empty();
|
||||
|
||||
// Iterate over the domain names
|
||||
var counter = 0;
|
||||
for (const [srcfile, domains] of Object.entries(domainFileList)) {
|
||||
|
||||
// Create a table row
|
||||
var row = $('<tr>');
|
||||
|
||||
// Create the domain name cell
|
||||
var domainClass = "validDomain";
|
||||
for (var i = 0; i < domains.length; i++){
|
||||
let thisDomain = domains[i];
|
||||
if (expiredDomains.includes(thisDomain)){
|
||||
domainClass = "expiredDomain";
|
||||
}
|
||||
}
|
||||
|
||||
var domainCell = $('<td class="' + domainClass +'">').html(domains.join("<br>"));
|
||||
row.append(domainCell);
|
||||
|
||||
var srcFileCell = $('<td>').text(srcfile);
|
||||
row.append(srcFileCell);
|
||||
|
||||
// Create the auto-renew checkbox cell
|
||||
let domainsEncoded = encodeURIComponent(JSON.stringify(domains));
|
||||
var checkboxCell = $(`<td domain="${domainsEncoded}" srcfile="${srcfile}">`);
|
||||
var checkbox = $(`<input name="${srcfile}">`).attr('type', 'checkbox');
|
||||
checkboxCell.append(checkbox);
|
||||
row.append(checkboxCell);
|
||||
|
||||
// Add the row to the table body
|
||||
tableBody.append(row);
|
||||
|
||||
counter++;
|
||||
}
|
||||
|
||||
if (Object.keys(domainFileList).length == 0){
|
||||
//No certificate in this system
|
||||
tableBody.append(`<tr>
|
||||
<td colspan="3"><i class="ui green circle check icon"></i> No certificate in use</td>
|
||||
</tr>`);
|
||||
}
|
||||
}
|
||||
|
||||
//Initiate domain table. If you needs to update the expired domain as well
|
||||
//call from initDomainFileList() instead
|
||||
function initDomainTable(){
|
||||
$.get("/api/cert/listdomains?compact=true", function(data){
|
||||
if (data.error != undefined){
|
||||
parent.msgbox(data.error, false);
|
||||
}else{
|
||||
renderDomainTable(data);
|
||||
}
|
||||
initAutoRenewPolicy();
|
||||
})
|
||||
}
|
||||
|
||||
function initDomainFileList() {
|
||||
$.ajax({
|
||||
url: "/api/acme/listExpiredDomains",
|
||||
method: "GET",
|
||||
success: function(response) {
|
||||
// Render domain table
|
||||
expiredDomains = response.domain;
|
||||
initDomainTable();
|
||||
//renderDomainTable(response.domain);
|
||||
},
|
||||
error: function(error) {
|
||||
console.log("Failed to fetch expired domains:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
initDomainFileList();
|
||||
|
||||
// Button click event handler for obtaining certificate
|
||||
$("#obtainButton").click(function() {
|
||||
$("#obtainButton").addClass("loading").addClass("disabled");
|
||||
obtainCertificate();
|
||||
});
|
||||
|
||||
// Obtain certificate from API
|
||||
function obtainCertificate() {
|
||||
var domains = $("#domainsInput").val();
|
||||
var filename = $("#filenameInput").val();
|
||||
var email = $("#caRegisterEmail").val();
|
||||
if (email == ""){
|
||||
parent.msgbox("ACME renew email is not set")
|
||||
return;
|
||||
}
|
||||
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,
|
||||
email: email,
|
||||
ca: ca,
|
||||
},
|
||||
success: function(response) {
|
||||
$("#obtainButton").removeClass("loading").removeClass("disabled");
|
||||
if (response.error) {
|
||||
console.log("Error:", response.error);
|
||||
// Show error message
|
||||
parent.msgbox(response.error, false, 12000);
|
||||
} else {
|
||||
console.log("Certificate renewed successfully");
|
||||
// Show success message
|
||||
parent.msgbox("Certificate renewed successfully");
|
||||
|
||||
// Renew the parent certificate list
|
||||
parent.initManagedDomainCertificateList();
|
||||
}
|
||||
},
|
||||
error: function(error) {
|
||||
$("#obtainButton").removeClass("loading").removeClass("disabled");
|
||||
console.log("Failed to renewed certificate:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkIfInputDomainIsMultiple(){
|
||||
var inputDomains = $("#domainsInput").val();
|
||||
if (inputDomains.includes(",")){
|
||||
$(".multiDomainOnly").show();
|
||||
}else{
|
||||
$(".multiDomainOnly").hide();
|
||||
}
|
||||
}
|
||||
|
||||
//Grab the longest common suffix of all domains
|
||||
//not that smart technically
|
||||
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);
|
||||
}
|
||||
|
||||
//Handle the renew now btn click
|
||||
function renewNow(){
|
||||
$.get("/api/acme/autoRenew/renewNow", function(data){
|
||||
if (data.error != undefined){
|
||||
parent.msgbox(data.error, false, 6000);
|
||||
}else{
|
||||
parent.msgbox(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function initAutoRenewPolicy(){
|
||||
$.get("/api/acme/autoRenew/listDomains", function(data){
|
||||
if (data.error != undefined){
|
||||
parent.msgbox(data.error, false)
|
||||
}else{
|
||||
if (data[0] == "*"){
|
||||
//Auto select and renew is enabled
|
||||
$("#renewAllSupported").parent().checkbox("set checked");
|
||||
}else{
|
||||
//This is a list of domain files
|
||||
data.forEach(function(name) {
|
||||
$('#domainTableBody input[type="checkbox"][name="' + name + '"]').prop('checked', true);
|
||||
});
|
||||
$("#domainCertFileTable").removeClass("disabled");
|
||||
$("#renewNowBtn").addClass("disabled");
|
||||
$("#renewSelectedButton").removeClass("disabled");
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function saveAutoRenewPolicy(){
|
||||
let autoRenewAll = $("#renewAllSupported").parent().checkbox("is checked");
|
||||
if (autoRenewAll == true){
|
||||
$.ajax({
|
||||
url: "/api/acme/autoRenew/setDomains",
|
||||
data: {opr: "setAuto"},
|
||||
success: function(data){
|
||||
parent.msgbox("Renew policy rule updated")
|
||||
}
|
||||
});
|
||||
}else{
|
||||
let checkedNames = [];
|
||||
$('#domainTableBody input[type="checkbox"]:checked').each(function() {
|
||||
checkedNames.push($(this).attr('name'));
|
||||
});
|
||||
|
||||
$.ajax({
|
||||
url: "/api/acme/autoRenew/setDomains",
|
||||
data: {opr: "setSelected", domains: JSON.stringify(checkedNames)},
|
||||
success: function(data){
|
||||
parent.msgbox("Renew policy rule updated")
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//Clear up the input field when page load
|
||||
$("#filenameInput").val("");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
100
src/web/snippet/configTools.html
Normal file
100
src/web/snippet/configTools.html
Normal file
@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Notes: This should be open in its original path-->
|
||||
<link rel="stylesheet" href="../script/semantic/semantic.min.css">
|
||||
<script src="../script/jquery-3.6.0.min.js"></script>
|
||||
<script src="../script/semantic/semantic.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<br>
|
||||
<div class="ui container">
|
||||
<div class="ui header">
|
||||
<div class="content">
|
||||
Config Export and Import Tool
|
||||
<div class="sub header">Painless migration with one click</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Backup Current Configs</h3>
|
||||
<p>This will download all your configuration on zoraxy in a zip file. This includes all the proxy configs and certificates. Please keep it somewhere safe and after migration, delete this if possible.</p>
|
||||
<div class="ui form">
|
||||
<div class="grouped fields">
|
||||
<label>Backup Mode</label>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" value="rules" name="backupmode" checked="checked">
|
||||
<label>Proxy Settings, Redirect Rules and Certificates Only</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui radio checkbox">
|
||||
<input type="radio" value="full" name="backupmode">
|
||||
<label>Full System Snapshot</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
<button class="ui basic button" onclick="downloadConfig();"><i class="ui blue download icon"></i> Download</button>
|
||||
<div class="ui divider"></div>
|
||||
|
||||
<h3>Restore from Config</h3>
|
||||
<p>You can restore your previous settings and database from a zip file config backup.
|
||||
<br><b style="color: rgba(255, 0, 0, 0.644);">RESTORE FULL SYSTEM SNAPSHOT WILL CAUSE THE SYSTEM TO SHUTDOWN AFTER COMPLETED. Make sure your Zoraxy is configured to work with systemd to automatic restart Zoraxy after system restore completed.<br>
|
||||
|
||||
</b></p>
|
||||
<form class="ui form" id="uploadForm" action="/api/conf/import" method="POST" enctype="multipart/form-data">
|
||||
<input type="file" name="file" id="fileInput" accept=".zip">
|
||||
<button style="margin-top: 0.6em;" class="ui basic button" type="submit"><i class="ui green upload icon"></i> Upload</button>
|
||||
</form>
|
||||
<small>The current config will be backup to ./conf_old{timestamp}. If you screw something up, you can always restore it by ssh to your host and restore the configs from the old folder.</small>
|
||||
<br><br>
|
||||
<button class="ui basic button" style="float: right;" onclick="parent.hideSideWrapper();"><i class="remove icon"></i> Cancel</button>
|
||||
</div>
|
||||
<script>
|
||||
$(".checkbox").checkbox();
|
||||
|
||||
function getCheckedRadioValue() {
|
||||
var checkedValue = $("input[name='backupmode']:checked").val();
|
||||
return checkedValue;
|
||||
}
|
||||
|
||||
|
||||
function downloadConfig(){
|
||||
let backupMode = getCheckedRadioValue();
|
||||
if (backupMode == "full"){
|
||||
window.open("/api/conf/export?includeDB=true");
|
||||
}else{
|
||||
window.open("/api/conf/export");
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("uploadForm").addEventListener("submit", function(event) {
|
||||
event.preventDefault(); // Prevent the form from submitting normally
|
||||
|
||||
var fileInput = document.getElementById("fileInput");
|
||||
var file = fileInput.files[0];
|
||||
if (!file) {
|
||||
alert("Missing file.");
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/conf/import", true);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
if (xhr.status === 200) {
|
||||
parent.msgbox("Config restore succeed. Restart Zoraxy to apply changes.")
|
||||
} else {
|
||||
parent.msgbox("Restore failed: " + xhr.responseText, false, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.send(formData);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
327
src/web/tools/https.html
Normal file
327
src/web/tools/https.html
Normal file
@ -0,0 +1,327 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="theme-color" content="#4b75ff">
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>HTTPS Setup Wizard | Zoraxy</title>
|
||||
<link rel="stylesheet" href="../script/semantic/semantic.min.css">
|
||||
<script src="../script/jquery-3.6.0.min.js"></script>
|
||||
<script src="../../script/ao_module.js"></script>
|
||||
<script src="../script/semantic/semantic.min.js"></script>
|
||||
<script src="../script/tablesort.js"></script>
|
||||
<link rel="stylesheet" href="shepherd.js/dist/css/shepherd.css"/>
|
||||
<script src="shepherd.js/dist/js/shepherd.min.js"></script>
|
||||
<link rel="stylesheet" href="../main.css">
|
||||
</head>
|
||||
<body>
|
||||
<br>
|
||||
<div class="ui container">
|
||||
<div class="ui yellow message">
|
||||
This Wizard require both client and server connected to the internet.
|
||||
<br><b>
|
||||
As different deployment methods might involve different network environment,
|
||||
this wizard is only provided for assistant and the correctness of the setup is not guaranteed.
|
||||
If you need to verify your TLS/SSL certificate installation is valid, please seek help
|
||||
from IT professionals.</b>
|
||||
</div>
|
||||
<div class="ui segment">
|
||||
<h3 class="ui header">
|
||||
HTTPS (TLS/SSL Certificate) Setup Wizard
|
||||
<div class="sub header">This tool help you setup https with your domain / subdomain on your Zoraxy host. <br>
|
||||
Follow the steps below to get started</div>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ui segment stepContainer" step="1">
|
||||
<h4 class="ui header">
|
||||
1. Setup Zoraxy to listen to port 80 or 443 and start listening
|
||||
<div class="sub header">ACME can only works on port 80 (or 80 redirected 443). Please make sure Zoarxy is listening to either one of the ports.</div>
|
||||
</h4>
|
||||
<button class="ui basic green button" onclick="checkStep(1, step1Callback, this);">Check Port Setup</button>
|
||||
<div class="checkResult" style="margin-top: 1em;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui segment stepContainer" step="2">
|
||||
<h4 class="ui header">
|
||||
2. If you are under NAT, setup Port Forward and forward external port 80 (<b style="color: rgb(206, 29, 29); font-weight:bolder;">and</b> 443, if you are using 443) to your Zoraxy's LAN IP address port 80 (and 443)
|
||||
<div class="sub header">If your Zoraxy server IP address starts with 192.168., you are mostly under a NAT router.</div>
|
||||
</h4>
|
||||
<small>The check function below will use public ip to check if port is opened. Make sure your host is reachable from the internet!<br>
|
||||
<b style="color: rgb(206, 29, 29); font-weight:bolder;">If you are using 443, you still need to forward port 80 for performing 80 to 443 redirect.</b></small><br>
|
||||
<button style="margin-top: 0.6em;" class="ui basic green button" onclick="checkStep(2, step2Callback, this);">Check Internet Reachable</button>
|
||||
<div class="checkResult" style="margin-top: 1em;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui segment stepContainer" step="3">
|
||||
<h4 class="ui header">
|
||||
3. Point your domain (or sub-domain) to your Zoraxy server public IP address
|
||||
<div class="sub header">DNS records might takes 5 - 10 minutes to take effect. If checking did not poss the first time, wait for a few minutes and retry.</div>
|
||||
</h4>
|
||||
<div class="ui fluid input">
|
||||
<input type="text" name="domain" placeholder="Your Domain / DNS name (e.g. dev.example.com)">
|
||||
</div>
|
||||
<br>
|
||||
<button class="ui basic green button" onclick="checkStep(3, step3Callback, this);">Check Domain Reachable</button>
|
||||
<div class="checkResult" style="margin-top: 1em;">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ui segment stepContainer" step="4">
|
||||
<h4 class="ui header">
|
||||
4. Request a public CA to assign you a certificate
|
||||
<div class="sub header">This process might take a few minutes and usually fully automated. If there are any error, you can see Zoraxy STDOUT / log for more information.</div>
|
||||
</h4>
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<label>Renewer Email</label>
|
||||
<div class="ui fluid input">
|
||||
<input id="caRegisterEmail" type="text" placeholder="webmaster@example.com">
|
||||
</div>
|
||||
<small>Your CA might send expire notification to you via this email.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<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 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" id="ca">
|
||||
<input type="hidden" name="ca">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="default text">Let's Encrypt</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<button id="obtainButton" class="ui green basic button" type="submit"><i class="green download icon"></i> Get Certificate</button>
|
||||
</div>
|
||||
<div class="ui green message" id="installSucc" style="display:none;">
|
||||
<i class="ui check icon"></i> Certificate for this domain has been installed. Visit the TLS/SSL tab for advance operations.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
$(".dropdown").dropdown();
|
||||
|
||||
function checkIfInputDomainIsMultiple(){
|
||||
var inputDomains = $("#domainsInput").val();
|
||||
if (inputDomains.includes(",")){
|
||||
$(".multiDomainOnly").show();
|
||||
}else{
|
||||
$(".multiDomainOnly").hide();
|
||||
}
|
||||
}
|
||||
|
||||
//Grab the longest common suffix of all domains
|
||||
//not that smart technically
|
||||
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(".")){
|
||||
alert("Auto Detect failed: Multiple Domains");
|
||||
return;
|
||||
}
|
||||
$("#filenameInput").val(longestSuffix);
|
||||
}
|
||||
|
||||
$("#obtainButton").click(function() {
|
||||
$("#obtainButton").addClass("loading").addClass("disabled");
|
||||
obtainCertificate();
|
||||
});
|
||||
|
||||
// Obtain certificate from API
|
||||
function obtainCertificate() {
|
||||
var domains = $("#domainsInput").val();
|
||||
var filename = $("#filenameInput").val();
|
||||
var email = $("#caRegisterEmail").val();
|
||||
if (email == ""){
|
||||
alert("ACME renew email is not set")
|
||||
return;
|
||||
}
|
||||
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{
|
||||
alert("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,
|
||||
email: email,
|
||||
ca: ca,
|
||||
},
|
||||
success: function(response) {
|
||||
$("#obtainButton").removeClass("loading").removeClass("disabled");
|
||||
if (response.error) {
|
||||
console.log("Error:", response.error);
|
||||
// Show error message
|
||||
alert(response.error);
|
||||
$("#installSucc").hide();
|
||||
} else {
|
||||
console.log("Certificate installed successfully");
|
||||
// Show success message
|
||||
//alert("Certificate installed successfully");
|
||||
$("#installSucc").show();
|
||||
}
|
||||
},
|
||||
error: function(error) {
|
||||
$("#obtainButton").removeClass("loading").removeClass("disabled");
|
||||
console.log("Failed to renewed certificate:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function step3Callback(resultContainer, data){
|
||||
if (data == true){
|
||||
$(resultContainer).html(`<div class="ui green message">
|
||||
<i class="ui check icon"></i> Domain is reachable and seems there is a HTTP server listening. Please move on to the next step.
|
||||
</div>`);
|
||||
}else{
|
||||
$(resultContainer).html(`<div class="ui red message">
|
||||
<i class="ui remove icon"></i> Domain is reachable but there are no HTTP server listening<br>
|
||||
Make sure you have point to the correct IP address and there are not another proxy server above Zoraxy.
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function step2Callback(resultContainer, data){
|
||||
if (data == true){
|
||||
$(resultContainer).html(`<div class="ui green message">
|
||||
<i class="ui check icon"></i> HTTP Server reachable from public IP address. Please move on to the next step.
|
||||
</div>`);
|
||||
}else{
|
||||
$(resultContainer).html(`<div class="ui red message">
|
||||
<i class="ui remove icon"></i> Server unreachable from public IP address<br>
|
||||
Check if you have correct NAT port forward setup in your home router, firewall and make sure network is reachable from the public internet.
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function step1Callback(resultContainer, data){
|
||||
if (data == true){
|
||||
$(resultContainer).html(`<div class="ui green message">
|
||||
<i class="ui check icon"></i> Supported listening port. Please move on to the next step.
|
||||
</div>`);
|
||||
}else{
|
||||
$(resultContainer).html(`<div class="ui red message">
|
||||
<i class="ui remove icon"></i> Invalid listening port.<br>
|
||||
Go to Status tab and change the listening port to 80 or 443
|
||||
</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function getStepContainerByNo(stepNo){
|
||||
let targetStepContainer = undefined;
|
||||
$(".stepContainer").each(function(){
|
||||
if ($(this).attr("step") == stepNo){
|
||||
let thisContainer = $(this);
|
||||
targetStepContainer = thisContainer;
|
||||
}
|
||||
});
|
||||
|
||||
return targetStepContainer;
|
||||
}
|
||||
function checkStep(stepNo, callback, btn){
|
||||
let targetContainer = getStepContainerByNo(stepNo);
|
||||
$(btn).addClass("loading");
|
||||
|
||||
//Load all the inputs
|
||||
data = {};
|
||||
$(targetContainer).find("input").each(function(){
|
||||
let key = $(this).attr("name")
|
||||
if (key != undefined){
|
||||
data[key] = $(this).val();
|
||||
}
|
||||
});
|
||||
$.ajax({
|
||||
url: "/api/acme/wizard?step=" + stepNo,
|
||||
data: data,
|
||||
success: function(data){
|
||||
$(btn).removeClass("loading");
|
||||
if (data.error != undefined){
|
||||
$(targetContainer).find(".checkResult").html(`
|
||||
<div class="ui red message">${data.error}</div>`);
|
||||
}else{
|
||||
callback($(targetContainer).find(".checkResult"), data);
|
||||
}
|
||||
},
|
||||
error: function(){
|
||||
$(btn).removeClass("loading");
|
||||
$(targetContainer).find(".checkResult").html(`
|
||||
<div class="ui red message">Server return an Unknown Error</div>`);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user