28 Commits

Author SHA1 Message Date
Toby Chui
4e32f31f0a Updated version number 2025-07-20 15:55:12 +08:00
Toby Chui
381184cd92 Merge pull request #746 from AnthonyMichaelTDM/plugin-improvements-api-keys
feat(plugins): Implement plugin API key management and authentication middleware
2025-07-20 15:45:14 +08:00
Toby Chui
223ae9e112 Merge pull request #751 from tobychui/v3.2.5
* Added new API endpoint /api/proxy/setTlsConfig (for HTTP Proxy Editor TLS tab)
* Refactored TLS certificate management APIs with new handlers
* Removed redundant functions from src/cert.go and delegated to tlsCertManager
* Code optimization in tlscert module
* Introduced a new constant CONF_FOLDER and updated configuration storage paths (phasing out hard coded paths) 
* Updated functions to set default TLS options when missing, default to SNI 

By @jemmy1794 

* Added Proxy Protocol v1 support in stream proxy
* Fixed Proxy UI bug
2025-07-20 14:27:56 +08:00
Toby Chui
aff1975c5a Updated version code and defs
- Updated version code
- Replaced hardcoded path of some config folder string with const value
2025-07-20 14:03:39 +08:00
Anthony Rubick
ad2519d894 fix(example plugin): update PermittedAPIEndpoints 2025-07-19 22:50:42 -07:00
Anthony Rubick
40f915f7fb fix: update example plugin 2025-07-19 22:35:44 -07:00
Anthony Rubick
e3e31d9f22 feat: add the plugin accessible endpoints 2025-07-19 22:29:22 -07:00
Anthony Rubick
be5f631b9f refactor: reuse PluginAuthMiddleware as AuthAgent for plugin accessible endpoints 2025-07-19 22:29:02 -07:00
Anthony Rubick
f9e51bfd27 remove unused functions 2025-07-19 22:23:19 -07:00
Anthony Rubick
39b5da36d9 refactor: partial revert of dd93f9a2c4 2025-07-19 22:23:19 -07:00
Toby Chui
5c6950ca56 Merge pull request #744 from jemmy1794/Stream_Proxy_v3.2.5
Fix Stream Proxy TCP/UDP selection not saved initially #742
2025-07-19 12:57:25 +08:00
Anthony Rubick
d187c32a8a feat(plugins): add an example api call to an accessible but unpermitted endpoint 2025-07-17 23:21:57 -07:00
Anthony Rubick
ed8f9b7337 fix(plugin-auth): check both endpoint and method 2025-07-17 23:18:40 -07:00
Anthony Rubick
46cfc02493 feat(webui/plugininfo): Add section for permitted API endpoints 2025-07-17 22:47:57 -07:00
Anthony Rubick
2d43890fcf feat: Add .gitignore for Docker to exclude example and src directories 2025-07-17 22:20:09 -07:00
Anthony Rubick
5a38c1d407 feat(plugins): Add API Call Example Plugin 2025-07-17 22:20:09 -07:00
Anthony Rubick
dd93f9a2c4 feat(plugins): Implement plugin API key management and authentication middleware
The purpose of this is to allow plugins to access certain internal APIs via

- Added PluginAPIKey and APIKeyManager for managing API keys associated with plugins.
- Introduced PluginAuthMiddleware to handle API key validation for plugin requests.
- Updated RouterDef to support plugin accessible endpoints with authentication.
- Modified various API registration functions to include plugin accessibility checks.
- Enhanced plugin lifecycle management to generate and revoke API keys as needed.
- Updated plugin specifications to include permitted API endpoints for access control.
2025-07-17 22:20:09 -07:00
Toby Chui
70b1ccfa6e Merge pull request #739 from AnthonyMichaelTDM/typo
fix: typo in dynamic_router.go
2025-07-16 14:20:02 +08:00
Anthony Rubick
100c1e9c04 fix: typo in dynamic_router.go
SniffResultAccpet should be SniffResultAccept
2025-07-15 22:05:06 -07:00
Jemmy
a33600d3e2 Fix Stream Proxy TCP/UDP selection not saved initially #742
- Reset the value of the form correctly in `streamprox.html`. Ref #742.
2025-07-16 08:11:25 +08:00
Toby Chui
c4c10d2130 Fixed #713
- Fixed sorting destination not working bug
2025-07-12 19:52:59 +08:00
Toby Chui
4d3d1b25cb Restructure TLS options
- Moved certification related functions into tlscert module
- Added specific host TLS behavior logic
- Added support for disabling SNI and manually overwrite preferred certificate to serve
- Fixed SSO requestHeaders null bug
2025-07-12 19:30:55 +08:00
Toby Chui
118b5e5114 Merge pull request #723 from 7brend7/fix-empty-sso-advanced-params
fix empty sso advanced parameters
2025-07-08 19:00:25 +08:00
Toby Chui
ad53b894c0 Update src/mod/auth/sso/forward/forward.go
Co-authored-by: James Elliott <james-d-elliott@users.noreply.github.com>
2025-07-08 12:38:08 +08:00
Toby Chui
a0a394885c Merge pull request #728 from PassiveLemon/hardening
Docker changes
2025-07-08 09:37:27 +08:00
PassiveLemon
51334a3a75 Docker: Switch to a python entrypoint 2025-07-07 13:34:50 -04:00
PassiveLemon
6f5fadc085 Docker: Do not automatically build Zoraxy plugins 2025-07-07 13:33:57 -04:00
Borys Anikiyenko
e225407b03 fix empty sso advanced parameters 2025-07-06 22:25:17 +03:00
44 changed files with 2546 additions and 520 deletions

10
.gitignore vendored
View File

@@ -29,8 +29,6 @@ src/Zoraxy_*_*
src/certs/*
src/rules/*
src/README.md
docker/ContainerTester.sh
docker/docker-compose.yaml
src/mod/acme/test/stackoverflow.pem
/tools/dns_challenge_update/code-gen/acmedns
/tools/dns_challenge_update/code-gen/lego
@@ -41,11 +39,15 @@ src/sys.uuid
src/zoraxy
src/log/
# dev-tags
/Dockerfile
/Entrypoint.sh
# docker testing stuff
docker/test/
docker/container-builder.sh
docker/docker-compose.yaml
# plugins
example/plugins/ztnc/ztnc.db
example/plugins/ztnc/authtoken.secret
@@ -58,3 +60,5 @@ sys.*
www/html/index.html
*.exe
/src/dist
/src/plugins

2
docker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
example/
src/

View File

@@ -34,34 +34,18 @@ RUN curl -Lo ZeroTierOne.tar.gz https://codeload.github.com/zerotier/ZeroTierOne
chmod 755 /usr/local/bin/zerotier-one
## Fetch plugin
FROM docker.io/golang:alpine AS fetch-plugin
RUN mkdir -p /opt/zoraxy/zoraxy_plugin/
RUN apk add --update --no-cache git
WORKDIR /opt/zoraxy/
RUN git clone https://github.com/aroz-online/zoraxy-official-plugins &&\
cp -r ./zoraxy-official-plugins/src/ztnc/mod/zoraxy_plugin/ /opt/zoraxy/zoraxy_plugin/
## Main
FROM docker.io/golang:alpine
FROM docker.io/alpine:latest
# If you build it yourself, you will need to add the example directory into the docker directory.
RUN apk add --update --no-cache python3 sudo netcat-openbsd libressl-dev openssh ca-certificates libc6-compat libstdc++ &&\
rm -rf /var/cache/apk/* /tmp/*
COPY --chmod=700 ./entrypoint.sh /opt/zoraxy/
COPY --chmod=700 ./build_plugins.sh /usr/local/bin/build_plugins
COPY --from=fetch-plugin --chmod=700 /opt/zoraxy/zoraxy_plugin/ /opt/zoraxy/zoraxy_plugin/
COPY --chmod=700 ./entrypoint.py /opt/zoraxy/
COPY --from=build-zerotier /usr/local/bin/zerotier-one /usr/local/bin/zerotier-one
COPY --from=build-zoraxy /usr/local/bin/zoraxy /usr/local/bin/zoraxy
RUN apk add --update --no-cache bash sudo netcat-openbsd libressl-dev openssh ca-certificates libc6-compat libstdc++ &&\
mkdir -p /opt/zoraxy/plugin/ &&\
RUN mkdir -p /opt/zoraxy/plugin/ &&\
echo "tun" | tee -a /etc/modules
WORKDIR /opt/zoraxy/config/
@@ -89,7 +73,7 @@ VOLUME [ "/opt/zoraxy/config/" ]
LABEL com.imuslab.zoraxy.container-identifier="Zoraxy"
ENTRYPOINT [ "/opt/zoraxy/entrypoint.sh" ]
ENTRYPOINT [ "python3", "-u", "/opt/zoraxy/entrypoint.py" ]
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 CMD nc -vz 127.0.0.1 $PORT || exit 1

View File

@@ -119,18 +119,14 @@ Or for Docker Compose:
### Plugins
You can find official plugins at https://github.com/aroz-online/zoraxy-official-plugins
Place your plugins inside the volume `/path/to/zoraxy/plugin/:/opt/zoraxy/plugin/` (Adjust to your actual install location). Any plugins you have added will then be built and used on the next restart.
> [!IMPORTANT]
> Plugins are currently experimental.
Zoraxy includes a (experimental) store to download and use official plugins right from inside Zoraxy, no preparation required.
For those looking to use custom plugins, build your plugins and place them inside the volume `/path/to/zoraxy/plugin/:/opt/zoraxy/plugin/` (Adjust to your actual install location).
### Building
To build the Docker image:
- Check out the repository/branch.
- Copy the Zoraxy `src/` and `example/` directory into the `docker/` (here) directory.
- Copy the Zoraxy `src/` directory into the `docker/` (here) directory.
- Run the build command with `docker build -t zoraxy_build .`
- You can now use the image `zoraxy_build`
- If you wish to change the image name, then modify`zoraxy_build` in the previous step and then build again.

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
echo "Copying zoraxy_plugin to all mods..."
for dir in "$1"/*; do
if [ -d "$dir" ]; then
cp -r "/opt/zoraxy/zoraxy_plugin/" "$dir/mod/"
fi
done
echo "Running go mod tidy and go build for all directories..."
for dir in "$1"/*; do
if [ -d "$dir" ]; then
cd "$dir" || exit 1
go mod tidy
go build
cd "$1" || exit 1
fi
done

128
docker/entrypoint.py Normal file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
import os
import signal
import subprocess
import sys
import time
zoraxy_proc = None
zerotier_proc = None
def getenv(key, default=None):
return os.environ.get(key, default)
def run(command):
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as e:
print(f"Command failed: {command} - {e}")
sys.exit(1)
def popen(command):
proc = subprocess.Popen(command)
time.sleep(1)
if proc.poll() is not None:
print(f"{command} exited early with code {proc.returncode}")
raise RuntimeError(f"Failed to start {command}")
return proc
def cleanup(_signum, _frame):
print("Shutdown signal received. Cleaning up...")
global zoraxy_proc, zerotier_proc
if zoraxy_proc and zoraxy_proc.poll() is None:
print("Terminating Zoraxy...")
zoraxy_proc.terminate()
if zerotier_proc and zerotier_proc.poll() is None:
print("Terminating ZeroTier-One...")
zerotier_proc.terminate()
if zoraxy_proc:
try:
zoraxy_proc.wait(timeout=8)
except subprocess.TimeoutExpired:
zoraxy_proc.kill()
zoraxy_proc.wait()
if zerotier_proc:
try:
zerotier_proc.wait(timeout=8)
except subprocess.TimeoutExpired:
zerotier_proc.kill()
zerotier_proc.wait()
try:
os.unlink("/var/lib/zerotier-one")
except FileNotFoundError:
pass
except Exception as e:
print(f"Failed to unlink ZeroTier socket: {e}")
sys.exit(0)
def start_zerotier():
print("Starting ZeroTier...")
global zerotier_proc
config_dir = "/opt/zoraxy/config/zerotier/"
zt_path = "/var/lib/zerotier-one"
os.makedirs(config_dir, exist_ok=True)
os.symlink(config_dir, zt_path, target_is_directory=True)
zerotier_proc = popen(["zerotier-one"])
def start_zoraxy():
print("Starting Zoraxy...")
global zoraxy_proc
zoraxy_args = [
"zoraxy",
f"-autorenew={getenv('AUTORENEW', '86400')}",
f"-cfgupgrade={getenv('CFGUPGRADE', 'true')}",
f"-db={getenv('DB', 'auto')}",
f"-docker={getenv('DOCKER', 'true')}",
f"-earlyrenew={getenv('EARLYRENEW', '30')}",
f"-fastgeoip={getenv('FASTGEOIP', 'false')}",
f"-mdns={getenv('MDNS', 'true')}",
f"-mdnsname={getenv('MDNSNAME', "''")}",
f"-noauth={getenv('NOAUTH', 'false')}",
f"-plugin={getenv('PLUGIN', '/opt/zoraxy/plugin/')}",
f"-port=:{getenv('PORT', '8000')}",
f"-sshlb={getenv('SSHLB', 'false')}",
f"-update_geoip={getenv('UPDATE_GEOIP', 'false')}",
f"-version={getenv('VERSION', 'false')}",
f"-webfm={getenv('WEBFM', 'true')}",
f"-webroot={getenv('WEBROOT', './www')}",
]
zoraxy_proc = popen(zoraxy_args)
def main():
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
print("Updating CA certificates...")
run(["update-ca-certificates"])
print("Updating GeoIP data...")
run(["zoraxy", "-update_geoip=true"])
os.chdir("/opt/zoraxy/config/")
if getenv("ZEROTIER", "false") == "true":
start_zerotier()
start_zoraxy()
signal.pause()
if __name__ == "__main__":
main()

View File

@@ -1,55 +0,0 @@
#!/usr/bin/env bash
cleanup() {
echo "Stop signal received. Shutting down..."
kill -TERM "$(pidof zoraxy)" &> /dev/null && echo "Zoraxy stopped."
kill -TERM "$(pidof zerotier-one)" &> /dev/null && echo "ZeroTier-One stopped."
unlink /var/lib/zerotier-one/zerotier/
exit 0
}
trap cleanup SIGTERM SIGINT TERM INT
update-ca-certificates && echo "CA certificates updated."
zoraxy -update_geoip=true && echo "GeoIP data updated ."
echo "Building plugins..."
cd /opt/zoraxy/plugin/ || exit 1
build_plugins "$PWD"
echo "Plugins built."
cd /opt/zoraxy/config/ || exit 1
if [ "$ZEROTIER" = "true" ]; then
if [ ! -d "/opt/zoraxy/config/zerotier/" ]; then
mkdir -p /opt/zoraxy/config/zerotier/
fi
ln -s /opt/zoraxy/config/zerotier/ /var/lib/zerotier-one
zerotier-one -d &
zerotierpid=$!
echo "ZeroTier daemon started."
fi
echo "Starting Zoraxy..."
zoraxy \
-autorenew="$AUTORENEW" \
-cfgupgrade="$CFGUPGRADE" \
-db="$DB" \
-docker="$DOCKER" \
-earlyrenew="$EARLYRENEW" \
-fastgeoip="$FASTGEOIP" \
-mdns="$MDNS" \
-mdnsname="$MDNSNAME" \
-noauth="$NOAUTH" \
-plugin="$PLUGIN" \
-port=:"$PORT" \
-sshlb="$SSHLB" \
-update_geoip="$UPDATE_GEOIP" \
-version="$VERSION" \
-webfm="$WEBFM" \
-webroot="$WEBROOT" \
&
zoraxypid=$!
wait "$zoraxypid"
wait "$zerotierpid"

View File

@@ -0,0 +1,3 @@
module aroz.org/zoraxy/api-call-example
go 1.24.5

View File

@@ -0,0 +1,54 @@
package main
import (
"fmt"
"net/http"
plugin "aroz.org/zoraxy/api-call-example/mod/zoraxy_plugin"
)
const (
PLUGIN_ID = "org.aroz.zoraxy.api_call_example"
UI_PATH = "/ui"
)
func main() {
// Serve the plugin intro spect
// This will print the plugin intro spect and exit if the -introspect flag is provided
runtimeCfg, err := plugin.ServeAndRecvSpec(&plugin.IntroSpect{
ID: PLUGIN_ID,
Name: "API Call Example Plugin",
Author: "Anthony Rubick",
AuthorContact: "",
Description: "An example plugin for making API calls",
Type: plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: UI_PATH,
/* API Access Control */
PermittedAPIEndpoints: []plugin.PermittedAPIEndpoint{
{
Method: http.MethodGet,
Endpoint: "/plugin/api/access/list",
Reason: "Used to display all configured Access Rules",
},
},
})
if err != nil {
fmt.Printf("Error serving introspect: %v\n", err)
return
}
// Start the HTTP server
http.HandleFunc(UI_PATH+"/", func(w http.ResponseWriter, r *http.Request) {
RenderUI(runtimeCfg, w, r)
})
serverAddr := fmt.Sprintf("127.0.0.1:%d", runtimeCfg.Port)
fmt.Printf("Starting API Call Example Plugin on %s\n", serverAddr)
http.ListenAndServe(serverAddr, nil)
}

View File

@@ -0,0 +1,19 @@
# Zoraxy Plugin
## Overview
This module serves as a template for building your own plugins for the Zoraxy Reverse Proxy. By copying this module to your plugin mod folder, you can create a new plugin with the necessary structure and components.
## Instructions
1. **Copy the Module:**
- Copy the entire `zoraxy_plugin` module to your plugin mod folder.
2. **Include the Structure:**
- Ensure that you maintain the directory structure and file organization as provided in this module.
3. **Modify as Needed:**
- Customize the copied module to implement the desired functionality for your plugin.
## Directory Structure
zoraxy_plugin: Handle -introspect and -configuration process required for plugin loading and startup
embed_webserver: Handle embeded web server routing and injecting csrf token to your plugin served UI pages

View File

@@ -0,0 +1,145 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"os"
"strings"
"time"
)
type PluginUiDebugRouter struct {
PluginID string //The ID of the plugin
TargetDir string //The directory where the UI files are stored
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginFileSystemUIRouter creates a new PluginUiRouter with file system
// The targetDir is the directory where the UI files are stored (e.g. ./www)
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginFileSystemUIRouter(pluginID string, targetDir string, handlerPrefix string) *PluginUiDebugRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiDebugRouter{
PluginID: pluginID,
TargetDir: targetDir,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiDebugRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from file system
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
//Check if the request is for a directory
//Check if the directory has an index.html file
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetDir + "/" + targetFilePath + "index.html"
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
if _, err := os.Stat(targetFilePath); err == nil {
//Serve the index.html file
targetFileContent, err := os.ReadFile(targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiDebugRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL.Path = rewrittenURL
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the file system
fsHandler := http.FileServer(http.Dir(p.TargetDir))
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, fsHandler).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiDebugRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the file system UI handler to the target http.ServeMux
func (p *PluginUiDebugRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,162 @@
package zoraxy_plugin
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
/*
Dynamic Path Handler
*/
type SniffResult int
const (
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
type SniffHandler func(*DynamicSniffForwardRequest) SniffResult
/*
RegisterDynamicSniffHandler registers a dynamic sniff handler for a path
You can decide to accept or skip the request based on the request header and paths
*/
func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http.ServeMux, handler SniffHandler) {
if !strings.HasSuffix(sniff_ingress, "/") {
sniff_ingress = sniff_ingress + "/"
}
mux.Handle(sniff_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p.enableDebugPrint {
fmt.Println("Request captured by dynamic sniff path: " + r.RequestURI)
}
// Decode the request payload
jsonBytes, err := io.ReadAll(r.Body)
if err != nil {
if p.enableDebugPrint {
fmt.Println("Error reading request body:", err)
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
payload, err := DecodeForwardRequestPayload(jsonBytes)
if err != nil {
if p.enableDebugPrint {
fmt.Println("Error decoding request payload:", err)
fmt.Print("Payload: ")
fmt.Println(string(jsonBytes))
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Get the forwarded request UUID
forwardUUID := r.Header.Get("X-Zoraxy-RequestID")
payload.requestUUID = forwardUUID
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {
w.WriteHeader(http.StatusNotImplemented)
w.Write([]byte("SKIP"))
}
}))
}
// RegisterDynamicCaptureHandle register the dynamic capture ingress path with a handler
func (p *PathRouter) RegisterDynamicCaptureHandle(capture_ingress string, mux *http.ServeMux, handlefunc func(http.ResponseWriter, *http.Request)) {
if !strings.HasSuffix(capture_ingress, "/") {
capture_ingress = capture_ingress + "/"
}
mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if p.enableDebugPrint {
fmt.Println("Request captured by dynamic capture path: " + r.RequestURI)
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, capture_ingress)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
if rewrittenURL == "" {
rewrittenURL = "/"
}
if !strings.HasPrefix(rewrittenURL, "/") {
rewrittenURL = "/" + rewrittenURL
}
r.RequestURI = rewrittenURL
handlefunc(w, r)
}))
}
/*
Sniffing and forwarding
The following functions are here to help with
sniffing and forwarding requests to the dynamic
router.
*/
// A custom request object to be used in the dynamic sniffing
type DynamicSniffForwardRequest struct {
Method string `json:"method"`
Hostname string `json:"hostname"`
URL string `json:"url"`
Header map[string][]string `json:"header"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
RequestURI string `json:"request_uri"`
Proto string `json:"proto"`
ProtoMajor int `json:"proto_major"`
ProtoMinor int `json:"proto_minor"`
/* Internal use */
rawRequest *http.Request `json:"-"`
requestUUID string `json:"-"`
}
// GetForwardRequestPayload returns a DynamicSniffForwardRequest object from an http.Request object
func EncodeForwardRequestPayload(r *http.Request) DynamicSniffForwardRequest {
return DynamicSniffForwardRequest{
Method: r.Method,
Hostname: r.Host,
URL: r.URL.String(),
Header: r.Header,
RemoteAddr: r.RemoteAddr,
Host: r.Host,
RequestURI: r.RequestURI,
Proto: r.Proto,
ProtoMajor: r.ProtoMajor,
ProtoMinor: r.ProtoMinor,
rawRequest: r,
}
}
// DecodeForwardRequestPayload decodes JSON bytes into a DynamicSniffForwardRequest object
func DecodeForwardRequestPayload(jsonBytes []byte) (DynamicSniffForwardRequest, error) {
var payload DynamicSniffForwardRequest
err := json.Unmarshal(jsonBytes, &payload)
if err != nil {
return DynamicSniffForwardRequest{}, err
}
return payload, nil
}
// GetRequest returns the original http.Request object, for debugging purposes
func (dsfr *DynamicSniffForwardRequest) GetRequest() *http.Request {
return dsfr.rawRequest
}
// GetRequestUUID returns the request UUID
// if this UUID is empty string, that might indicate the request
// is not coming from the dynamic router
func (dsfr *DynamicSniffForwardRequest) GetRequestUUID() string {
return dsfr.requestUUID
}

View File

@@ -0,0 +1,156 @@
package zoraxy_plugin
import (
"embed"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type PluginUiRouter struct {
PluginID string //The ID of the plugin
TargetFs *embed.FS //The embed.FS where the UI files are stored
TargetFsPrefix string //The prefix of the embed.FS where the UI files are stored, e.g. /web
HandlerPrefix string //The prefix of the handler used to route this router, e.g. /ui
EnableDebug bool //Enable debug mode
terminateHandler func() //The handler to be called when the plugin is terminated
}
// NewPluginEmbedUIRouter creates a new PluginUiRouter with embed.FS
// The targetFsPrefix is the prefix of the embed.FS where the UI files are stored
// The targetFsPrefix should be relative to the root of the embed.FS
// The targetFsPrefix should start with a slash (e.g. /web) that corresponds to the root folder of the embed.FS
// The handlerPrefix is the prefix of the handler used to route this router
// The handlerPrefix should start with a slash (e.g. /ui) that matches the http.Handle path
// All prefix should not end with a slash
func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter {
//Make sure all prefix are in /prefix format
if !strings.HasPrefix(targetFsPrefix, "/") {
targetFsPrefix = "/" + targetFsPrefix
}
targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/")
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
//Return the PluginUiRouter
return &PluginUiRouter{
PluginID: pluginID,
TargetFs: targetFs,
TargetFsPrefix: targetFsPrefix,
HandlerPrefix: handlerPrefix,
}
}
func (p *PluginUiRouter) populateCSRFToken(r *http.Request, fsHandler http.Handler) http.Handler {
//Get the CSRF token from header
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
//Return the middleware
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if the request is for an HTML file
if strings.HasSuffix(r.URL.Path, ".html") {
//Read the target file from embed.FS
targetFilePath := strings.TrimPrefix(r.URL.Path, "/")
targetFilePath = p.TargetFsPrefix + "/" + targetFilePath
targetFilePath = strings.TrimPrefix(targetFilePath, "/")
targetFileContent, err := fs.ReadFile(*p.TargetFs, targetFilePath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := string(targetFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
} else if strings.HasSuffix(r.URL.Path, "/") {
// Check if the directory has an index.html file
indexFilePath := strings.TrimPrefix(r.URL.Path, "/") + "index.html"
indexFilePath = p.TargetFsPrefix + "/" + indexFilePath
indexFilePath = strings.TrimPrefix(indexFilePath, "/")
indexFileContent, err := fs.ReadFile(*p.TargetFs, indexFilePath)
if err == nil {
body := string(indexFileContent)
body = strings.ReplaceAll(body, "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
//Call the next handler
fsHandler.ServeHTTP(w, r)
})
}
// GetHttpHandler returns the http.Handler for the PluginUiRouter
func (p *PluginUiRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//Remove the plugin UI handler path prefix
if p.EnableDebug {
fmt.Print("Request URL:", r.URL.Path, " rewriting to ")
}
rewrittenURL := r.RequestURI
rewrittenURL = strings.TrimPrefix(rewrittenURL, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL, _ = url.Parse(rewrittenURL)
r.RequestURI = rewrittenURL
if p.EnableDebug {
fmt.Println(r.URL.Path)
}
//Serve the file from the embed.FS
subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/"))
if err != nil {
fmt.Println(err.Error())
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// Replace {{csrf_token}} with the actual CSRF token and serve the file
p.populateCSRFToken(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers the terminate handler for the PluginUiRouter
// The terminate handler will be called when the plugin is terminated from Zoraxy plugin manager
// if mux is nil, the handler will be registered to http.DefaultServeMux
func (p *PluginUiRouter) RegisterTerminateHandler(termFunc func(), mux *http.ServeMux) {
p.terminateHandler = termFunc
if mux == nil {
mux = http.DefaultServeMux
}
mux.HandleFunc(p.HandlerPrefix+"/term", func(w http.ResponseWriter, r *http.Request) {
p.terminateHandler()
w.WriteHeader(http.StatusOK)
go func() {
//Make sure the response is sent before the plugin is terminated
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// Attach the embed UI handler to the target http.ServeMux
func (p *PluginUiRouter) AttachHandlerToMux(mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
p.HandlerPrefix = strings.TrimSuffix(p.HandlerPrefix, "/")
mux.Handle(p.HandlerPrefix+"/", p.Handler())
}

View File

@@ -0,0 +1,105 @@
package zoraxy_plugin
import (
"fmt"
"net/http"
"sort"
"strings"
)
type PathRouter struct {
enableDebugPrint bool
pathHandlers map[string]http.Handler
defaultHandler http.Handler
}
// NewPathRouter creates a new PathRouter
func NewPathRouter() *PathRouter {
return &PathRouter{
enableDebugPrint: false,
pathHandlers: make(map[string]http.Handler),
}
}
// RegisterPathHandler registers a handler for a path
func (p *PathRouter) RegisterPathHandler(path string, handler http.Handler) {
path = strings.TrimSuffix(path, "/")
p.pathHandlers[path] = handler
}
// RemovePathHandler removes a handler for a path
func (p *PathRouter) RemovePathHandler(path string) {
delete(p.pathHandlers, path)
}
// SetDefaultHandler sets the default handler for the router
// This handler will be called if no path handler is found
func (p *PathRouter) SetDefaultHandler(handler http.Handler) {
p.defaultHandler = handler
}
// SetDebugPrintMode sets the debug print mode
func (p *PathRouter) SetDebugPrintMode(enable bool) {
p.enableDebugPrint = enable
}
// StartStaticCapture starts the static capture ingress
func (p *PathRouter) RegisterStaticCaptureHandle(capture_ingress string, mux *http.ServeMux) {
if !strings.HasSuffix(capture_ingress, "/") {
capture_ingress = capture_ingress + "/"
}
mux.Handle(capture_ingress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p.staticCaptureServeHTTP(w, r)
}))
}
// staticCaptureServeHTTP serves the static capture path using user defined handler
func (p *PathRouter) staticCaptureServeHTTP(w http.ResponseWriter, r *http.Request) {
capturePath := r.Header.Get("X-Zoraxy-Capture")
if capturePath != "" {
if p.enableDebugPrint {
fmt.Printf("Using capture path: %s\n", capturePath)
}
originalURI := r.Header.Get("X-Zoraxy-Uri")
r.URL.Path = originalURI
if handler, ok := p.pathHandlers[capturePath]; ok {
handler.ServeHTTP(w, r)
return
}
}
p.defaultHandler.ServeHTTP(w, r)
}
func (p *PathRouter) PrintRequestDebugMessage(r *http.Request) {
if p.enableDebugPrint {
fmt.Printf("Capture Request with path: %s \n\n**Request Headers** \n\n", r.URL.Path)
keys := make([]string, 0, len(r.Header))
for key := range r.Header {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
for _, value := range r.Header[key] {
fmt.Printf("%s: %s\n", key, value)
}
}
fmt.Printf("\n\n**Request Details**\n\n")
fmt.Printf("Method: %s\n", r.Method)
fmt.Printf("URL: %s\n", r.URL.String())
fmt.Printf("Proto: %s\n", r.Proto)
fmt.Printf("Host: %s\n", r.Host)
fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
fmt.Printf("RequestURI: %s\n", r.RequestURI)
fmt.Printf("ContentLength: %d\n", r.ContentLength)
fmt.Printf("TransferEncoding: %v\n", r.TransferEncoding)
fmt.Printf("Close: %v\n", r.Close)
fmt.Printf("Form: %v\n", r.Form)
fmt.Printf("PostForm: %v\n", r.PostForm)
fmt.Printf("MultipartForm: %v\n", r.MultipartForm)
fmt.Printf("Trailer: %v\n", r.Trailer)
fmt.Printf("RemoteAddr: %s\n", r.RemoteAddr)
fmt.Printf("RequestURI: %s\n", r.RequestURI)
}
}

View File

@@ -0,0 +1,187 @@
package zoraxy_plugin
import (
"encoding/json"
"fmt"
"os"
"strings"
)
/*
Plugins Includes.go
This file is copied from Zoraxy source code
You can always find the latest version under mod/plugins/includes.go
Usually this file are backward compatible
*/
type PluginType int
const (
PluginType_Router PluginType = 0 //Router Plugin, used for handling / routing / forwarding traffic
PluginType_Utilities PluginType = 1 //Utilities Plugin, used for utilities like Zerotier or Static Web Server that do not require interception with the dpcore
)
type StaticCaptureRule struct {
CapturePath string `json:"capture_path"`
//To be expanded
}
type ControlStatusCode int
const (
ControlStatusCode_CAPTURED ControlStatusCode = 280 //Traffic captured by plugin, ask Zoraxy not to process the traffic
ControlStatusCode_UNHANDLED ControlStatusCode = 284 //Traffic not handled by plugin, ask Zoraxy to process the traffic
ControlStatusCode_ERROR ControlStatusCode = 580 //Error occurred while processing the traffic, ask Zoraxy to process the traffic and log the error
)
type SubscriptionEvent struct {
EventName string `json:"event_name"`
EventSource string `json:"event_source"`
Payload string `json:"payload"` //Payload of the event, can be empty
}
type RuntimeConstantValue struct {
ZoraxyVersion string `json:"zoraxy_version"`
ZoraxyUUID string `json:"zoraxy_uuid"`
DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not
}
type PermittedAPIEndpoint struct {
Method string `json:"method"` //HTTP method for the API endpoint (e.g., GET, POST)
Endpoint string `json:"endpoint"` //The API endpoint that the plugin can access
Reason string `json:"reason"` //The reason why the plugin needs to access this endpoint
}
/*
IntroSpect Payload
When the plugin is initialized with -introspect flag,
the plugin shell return this payload as JSON and exit
*/
type IntroSpect struct {
/* Plugin metadata */
ID string `json:"id"` //Unique ID of your plugin, recommended using your own domain in reverse like com.yourdomain.pluginname
Name string `json:"name"` //Name of your plugin
Author string `json:"author"` //Author name of your plugin
AuthorContact string `json:"author_contact"` //Author contact of your plugin, like email
Description string `json:"description"` //Description of your plugin
URL string `json:"url"` //URL of your plugin
Type PluginType `json:"type"` //Type of your plugin, Router(0) or Utilities(1)
VersionMajor int `json:"version_major"` //Major version of your plugin
VersionMinor int `json:"version_minor"` //Minor version of your plugin
VersionPatch int `json:"version_patch"` //Patch version of your plugin
/*
Endpoint Settings
*/
/*
Static Capture Settings
Once plugin is enabled these rules always applies to the enabled HTTP Proxy rule
This is faster than dynamic capture, but less flexible
*/
StaticCapturePaths []StaticCaptureRule `json:"static_capture_paths"` //Static capture paths of your plugin, see Zoraxy documentation for more details
StaticCaptureIngress string `json:"static_capture_ingress"` //Static capture ingress path of your plugin (e.g. /s_handler)
/*
Dynamic Capture Settings
Once plugin is enabled, these rules will be captured and forward to plugin sniff
if the plugin sniff returns 280, the traffic will be captured
otherwise, the traffic will be forwarded to the next plugin
This is slower than static capture, but more flexible
*/
DynamicCaptureSniff string `json:"dynamic_capture_sniff"` //Dynamic capture sniff path of your plugin (e.g. /d_sniff)
DynamicCaptureIngress string `json:"dynamic_capture_ingress"` //Dynamic capture ingress path of your plugin (e.g. /d_handler)
/* UI Path for your plugin */
UIPath string `json:"ui_path"` //UI path of your plugin (e.g. /ui), will proxy the whole subpath tree to Zoraxy Web UI as plugin UI
/* Subscriptions Settings */
SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered
SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details
/* API Access Control */
PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` //List of API endpoints this plugin can access, and a description of why the plugin needs to access this endpoint
}
/*
ServeIntroSpect Function
This function will check if the plugin is initialized with -introspect flag,
if so, it will print the intro spect and exit
Place this function at the beginning of your plugin main function
*/
func ServeIntroSpect(pluginSpect *IntroSpect) {
if len(os.Args) > 1 && os.Args[1] == "-introspect" {
//Print the intro spect and exit
jsonData, _ := json.MarshalIndent(pluginSpect, "", " ")
fmt.Println(string(jsonData))
os.Exit(0)
}
}
/*
ConfigureSpec Payload
Zoraxy will start your plugin with -configure flag,
the plugin shell read this payload as JSON and configure itself
by the supplied values like starting a web server at given port
that listens to 127.0.0.1:port
*/
type ConfigureSpec struct {
Port int `json:"port"` //Port to listen
RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
APIKey string `json:"api_key,omitempty"` //API key for accessing Zoraxy APIs, if the plugin has permitted endpoints
ZoraxyPort int `json:"zoraxy_port,omitempty"` //The port that Zoraxy is running on, used for making API calls to Zoraxy
//To be expanded
}
/*
RecvExecuteConfigureSpec Function
This function will read the configure spec from Zoraxy
and return the ConfigureSpec object
Place this function after ServeIntroSpect function in your plugin main function
*/
func RecvConfigureSpec() (*ConfigureSpec, error) {
for i, arg := range os.Args {
if strings.HasPrefix(arg, "-configure=") {
var configSpec ConfigureSpec
if err := json.Unmarshal([]byte(arg[11:]), &configSpec); err != nil {
return nil, err
}
return &configSpec, nil
} else if arg == "-configure" {
var configSpec ConfigureSpec
var nextArg string
if len(os.Args) > i+1 {
nextArg = os.Args[i+1]
if err := json.Unmarshal([]byte(nextArg), &configSpec); err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("No port specified after -configure flag")
}
return &configSpec, nil
}
}
return nil, fmt.Errorf("No -configure flag found")
}
/*
ServeAndRecvSpec Function
This function will serve the intro spect and return the configure spec
See the ServeIntroSpect and RecvConfigureSpec for more details
*/
func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) {
ServeIntroSpect(pluginSpect)
return RecvConfigureSpec()
}

View File

@@ -0,0 +1,272 @@
package main
import (
"fmt"
"html"
"net/http"
"net/http/httputil"
"strconv"
plugin "aroz.org/zoraxy/api-call-example/mod/zoraxy_plugin"
)
func allowedEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to the permitted endpoint
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/access/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Make sure to set the Authorization header
req.Header.Set("Authorization", "Bearer "+cfg.APIKey) // Use the API key from the runtime config
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
// Check if the response status is OK
if resp.StatusCode != http.StatusOK {
return string(respDump), fmt.Errorf("received non-OK response status %d", resp.StatusCode)
}
return string(respDump), nil
}
func allowedEndpointInvalidKey(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to the permitted endpoint with an invalid key
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/access/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use an invalid API key
req.Header.Set("Authorization", "Bearer invalid-key")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func unaccessibleEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to an endpoint that is not permitted
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/api/acme/listExpiredDomains", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use the API key from the runtime config
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func unpermittedEndpoint(cfg *plugin.ConfigureSpec) (string, error) {
// Make an API call to an endpoint that is plugin-accessible but is not permitted
client := &http.Client{}
apiURL := fmt.Sprintf("http://localhost:%d/plugin/api/proxy/list", cfg.ZoraxyPort)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %v", err)
}
// Use the API key from the runtime config
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error making API call: %v", err)
}
defer resp.Body.Close()
respDump, err := httputil.DumpResponse(resp, true)
if err != nil {
return "", fmt.Errorf("error dumping response: %v", err)
}
return string(respDump), nil
}
func RenderUI(config *plugin.ConfigureSpec, w http.ResponseWriter, r *http.Request) {
// make several types of API calls to demonstrate the plugin functionality
accessList, err := allowedEndpoint(config)
var RenderedAccessListHTML string
if err != nil {
if accessList != "" {
RenderedAccessListHTML = fmt.Sprintf("<p>Error fetching access list: %v</p><pre>%s</pre>", err, html.EscapeString(accessList))
} else {
RenderedAccessListHTML = fmt.Sprintf("<p>Error fetching access list: %v</p>", err)
}
} else {
// Render the access list as HTML
RenderedAccessListHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(accessList))
}
// Make an API call with an invalid key
invalidKeyResponse, err := allowedEndpointInvalidKey(config)
var RenderedInvalidKeyResponseHTML string
if err != nil {
RenderedInvalidKeyResponseHTML = fmt.Sprintf("<p>Error with invalid key: %v</p>", err)
} else {
// Render the invalid key response as HTML
RenderedInvalidKeyResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(invalidKeyResponse))
}
// Make an API call to an endpoint that is not plugin-accessible
unaccessibleResponse, err := unaccessibleEndpoint(config)
var RenderedUnaccessibleResponseHTML string
if err != nil {
RenderedUnaccessibleResponseHTML = fmt.Sprintf("<p>Error with unaccessible endpoint: %v</p>", err)
} else {
// Render the unaccessible response as HTML
RenderedUnaccessibleResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(unaccessibleResponse))
}
// Make an API call to an endpoint that is plugin-accessible but is not permitted
unpermittedResponse, err := unpermittedEndpoint(config)
var RenderedUnpermittedResponseHTML string
if err != nil {
RenderedUnpermittedResponseHTML = fmt.Sprintf("<p>Error with unpermitted endpoint: %v</p>", err)
} else {
// Render the unpermitted response as HTML
RenderedUnpermittedResponseHTML = fmt.Sprintf("<pre>%s</pre>", html.EscapeString(unpermittedResponse))
}
// Render the UI for the plugin
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
html := `
<!DOCTYPE html>
<html>
<head>
<title>API Call Example Plugin UI</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="/main.css">
<script src="/script/jquery-3.6.0.min.js"></script>
<style>
.response-block {
background-color: var(--theme_bg_primary);
border: 1px solid var(--theme_divider);
border-radius: 8px;
padding: 20px;
margin: 15px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease;
}
.response-block:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.response-block h3 {
margin-top: 0;
color: var(--text_color);
border-bottom: 2px solid #007bff;
padding-bottom: 8px;
}
.response-block.success {
border-left: 4px solid #28a745;
}
.response-block.error {
border-left: 4px solid #dc3545;
}
.response-block.warning {
border-left: 4px solid #ffc107;
}
.response-content {
margin-top: 10px;
}
.response-content pre {
background-color: var(--theme_highlight);
border: 1px solid var(--theme_divider);
border-radius: 4px;
padding: 10px;
overflow: auto;
font-size: 12px;
line-height: 1.4;
height: 200px;
max-height: 80vh;
min-height: 100px;
resize: vertical;
box-sizing: border-box;
}
</style>
</head>
<body>
<!-- Dark theme script must be included after body tag-->
<link rel="stylesheet" href="/darktheme.css">
<script src="/script/darktheme.js"></script>
<h1>Welcome to the API Call Example Plugin UI</h1>
<p>Plugin is running on port: ` + strconv.Itoa(config.Port) + `</p>
<h2>API Call Examples</h2>
<div class="response-block success">
<h3>✅ Allowed Endpoint (Valid API Key)</h3>
<p>Making a GET request to <code>/plugin/api/access/list</code> with a valid API key:</p>
<div class="response-content">
` + RenderedAccessListHTML + `
</div>
</div>
<div class="response-block warning">
<h3>⚠️ Invalid API Key</h3>
<p>Making a GET request to <code>/plugin/api/access/list</code> with an invalid API key:</p>
<div class="response-content">
` + RenderedInvalidKeyResponseHTML + `
</div>
</div>
<div class="response-block warning">
<h3>⚠️ Unpermitted Endpoint</h3>
<p>Making a GET request to <code>/plugin/api/proxy/list</code> (not a permitted endpoint):</p>
<div class="response-content">
` + RenderedUnpermittedResponseHTML + `
</div>
</div>
<div class="response-block error">
<h3>❌ Disallowed Endpoint</h3>
<p>Making a GET request to <code>/api/acme/listExpiredDomains</code> (not a plugin-accessible endpoint):</p>
<div class="response-content">
` + RenderedUnaccessibleResponseHTML + `
</div>
</div>
</body>
</html>`
w.Write([]byte(html))
}

View File

@@ -72,15 +72,20 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
// Register the APIs for TLS / SSL certificate management functions
func RegisterTLSAPIs(authRouter *auth.RouterDef) {
//Global certificate settings
authRouter.HandleFunc("/api/cert/tls", handleToggleTLSProxy)
authRouter.HandleFunc("/api/cert/tlsRequireLatest", handleSetTlsRequireLatest)
authRouter.HandleFunc("/api/cert/upload", handleCertUpload)
authRouter.HandleFunc("/api/cert/download", handleCertDownload)
authRouter.HandleFunc("/api/cert/list", handleListCertificate)
authRouter.HandleFunc("/api/cert/listdomains", handleListDomains)
authRouter.HandleFunc("/api/cert/checkDefault", handleDefaultCertCheck)
authRouter.HandleFunc("/api/cert/delete", handleCertRemove)
authRouter.HandleFunc("/api/cert/resolve", handleCertTryResolve)
authRouter.HandleFunc("/api/cert/setPreferredCertificate", handleSetDomainPreferredCertificate)
//Certificate store functions
authRouter.HandleFunc("/api/cert/upload", tlsCertManager.HandleCertUpload)
authRouter.HandleFunc("/api/cert/download", tlsCertManager.HandleCertDownload)
authRouter.HandleFunc("/api/cert/list", tlsCertManager.HandleListCertificate)
authRouter.HandleFunc("/api/cert/listdomains", tlsCertManager.HandleListDomains)
authRouter.HandleFunc("/api/cert/checkDefault", tlsCertManager.HandleDefaultCertCheck)
authRouter.HandleFunc("/api/cert/delete", tlsCertManager.HandleCertRemove)
authRouter.HandleFunc("/api/cert/selfsign", tlsCertManager.HandleSelfSignCertGenerate)
}
// Register the APIs for Authentication handlers like Forward Auth and OAUTH2

View File

@@ -1,188 +1,14 @@
package main
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"imuslab.com/zoraxy/mod/acme"
"imuslab.com/zoraxy/mod/utils"
)
// Check if the default certificates is correctly setup
func handleDefaultCertCheck(w http.ResponseWriter, r *http.Request) {
type CheckResult struct {
DefaultPubExists bool
DefaultPriExists bool
}
pub, pri := tlsCertManager.DefaultCertExistsSep()
js, _ := json.Marshal(CheckResult{
pub,
pri,
})
utils.SendJSONResponse(w, string(js))
}
// Return a list of domains where the certificates covers
func handleListCertificate(w http.ResponseWriter, r *http.Request) {
filenames, err := tlsCertManager.ListCertDomains()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
showDate, _ := utils.GetPara(r, "date")
if showDate == "true" {
type CertInfo struct {
Domain string
LastModifiedDate string
ExpireDate string
RemainingDays int
UseDNS bool
}
results := []*CertInfo{}
for _, filename := range filenames {
certFilepath := filepath.Join(tlsCertManager.CertStore, filename+".pem")
//keyFilepath := filepath.Join(tlsCertManager.CertStore, filename+".key")
fileInfo, err := os.Stat(certFilepath)
if err != nil {
utils.SendErrorResponse(w, "invalid domain certificate discovered: "+filename)
return
}
modifiedTime := fileInfo.ModTime().Format("2006-01-02 15:04:05")
certExpireTime := "Unknown"
certBtyes, err := os.ReadFile(certFilepath)
expiredIn := 0
if err != nil {
//Unable to load this file
continue
} else {
//Cert loaded. Check its expire time
block, _ := pem.Decode(certBtyes)
if block != nil {
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)
}
}
}
certInfoFilename := filepath.Join(tlsCertManager.CertStore, filename+".json")
useDNSValidation := false //Default to false for HTTP TLS certificates
certInfo, err := acme.LoadCertInfoJSON(certInfoFilename) //Note: Not all certs have info json
if err == nil {
useDNSValidation = certInfo.UseDNS
}
thisCertInfo := CertInfo{
Domain: filename,
LastModifiedDate: modifiedTime,
ExpireDate: certExpireTime,
RemainingDays: expiredIn,
UseDNS: useDNSValidation,
}
results = append(results, &thisCertInfo)
}
// convert ExpireDate to date object and sort asc
sort.Slice(results, func(i, j int) bool {
date1, _ := time.Parse("2006-01-02 15:04:05", results[i].ExpireDate)
date2, _ := time.Parse("2006-01-02 15:04:05", results[j].ExpireDate)
return date1.Before(date2)
})
js, _ := json.Marshal(results)
w.Header().Set("Content-Type", "application/json")
w.Write(js)
} else {
response, err := json.Marshal(filenames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
}
// 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
SystemWideLogger.PrintAndLog("TLS", "Unable to load certificate: "+certFilepath, err)
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 := true //Default to true
@@ -193,11 +19,12 @@ func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) {
sysdb.Read("settings", "usetls", &currentTlsSetting)
}
if r.Method == http.MethodGet {
switch r.Method {
case http.MethodGet:
//Get the current status
js, _ := json.Marshal(currentTlsSetting)
utils.SendJSONResponse(w, string(js))
} else if r.Method == http.MethodPost {
case http.MethodPost:
newState, err := utils.PostBool(r, "set")
if err != nil {
utils.SendErrorResponse(w, "new state not set or invalid")
@@ -213,7 +40,7 @@ func handleToggleTLSProxy(w http.ResponseWriter, r *http.Request) {
dynamicProxyRouter.UpdateTLSSetting(false)
}
utils.SendOK(w)
} else {
default:
http.Error(w, "405 - Method not allowed", http.StatusMethodNotAllowed)
}
}
@@ -231,135 +58,21 @@ func handleSetTlsRequireLatest(w http.ResponseWriter, r *http.Request) {
js, _ := json.Marshal(reqLatestTLS)
utils.SendJSONResponse(w, string(js))
} else {
if newState == "true" {
switch newState {
case "true":
sysdb.Write("settings", "forceLatestTLS", true)
SystemWideLogger.Println("Updating minimum TLS version to v1.2 or above")
dynamicProxyRouter.UpdateTLSVersion(true)
} else if newState == "false" {
case "false":
sysdb.Write("settings", "forceLatestTLS", false)
SystemWideLogger.Println("Updating minimum TLS version to v1.0 or above")
dynamicProxyRouter.UpdateTLSVersion(false)
} else {
default:
utils.SendErrorResponse(w, "invalid state given")
}
}
}
// Handle download of the selected certificate
func handleCertDownload(w http.ResponseWriter, r *http.Request) {
// get the certificate name
certname, err := utils.GetPara(r, "certname")
if err != nil {
utils.SendErrorResponse(w, "invalid certname given")
return
}
certname = filepath.Base(certname) //prevent path escape
// check if the cert exists
pubKey := filepath.Join(filepath.Join("./conf/certs"), certname+".key")
priKey := filepath.Join(filepath.Join("./conf/certs"), certname+".pem")
if utils.FileExists(pubKey) && utils.FileExists(priKey) {
//Zip them and serve them via http download
seeking, _ := utils.GetBool(r, "seek")
if seeking {
//This request only check if the key exists. Do not provide download
utils.SendOK(w)
return
}
//Serve both file in zip
zipTmpFolder := "./tmp/download"
os.MkdirAll(zipTmpFolder, 0775)
zipFileName := filepath.Join(zipTmpFolder, certname+".zip")
err := utils.ZipFiles(zipFileName, pubKey, priKey)
if err != nil {
http.Error(w, "Failed to create zip file", http.StatusInternalServerError)
return
}
defer os.Remove(zipFileName) // Clean up the zip file after serving
// Serve the zip file
w.Header().Set("Content-Disposition", "attachment; filename=\""+certname+"_export.zip\"")
w.Header().Set("Content-Type", "application/zip")
http.ServeFile(w, r, zipFileName)
} else {
//Not both key exists
utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store")
return
}
}
// Handle upload of the certificate
func handleCertUpload(w http.ResponseWriter, r *http.Request) {
// check if request method is POST
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// get the key type
keytype, err := utils.GetPara(r, "ktype")
overWriteFilename := ""
if err != nil {
http.Error(w, "Not defined key type (pub / pri)", http.StatusBadRequest)
return
}
// get the domain
domain, err := utils.GetPara(r, "domain")
if err != nil {
//Assume localhost
domain = "default"
}
if keytype == "pub" {
overWriteFilename = domain + ".pem"
} else if keytype == "pri" {
overWriteFilename = domain + ".key"
} else {
http.Error(w, "Not supported keytype: "+keytype, http.StatusBadRequest)
return
}
// parse multipart form data
err = r.ParseMultipartForm(10 << 20) // 10 MB
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
// get file from form data
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to get file", http.StatusBadRequest)
return
}
defer file.Close()
// create file in upload directory
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
}
defer f.Close()
// copy file contents to destination file
_, err = io.Copy(f, file)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
//Update cert list
tlsCertManager.UpdateLoadedCertList()
// send response
fmt.Fprintln(w, "File upload successful!")
}
func handleCertTryResolve(w http.ResponseWriter, r *http.Request) {
// get the domain
domain, err := utils.GetPara(r, "domain")
@@ -441,15 +154,40 @@ func handleCertTryResolve(w http.ResponseWriter, r *http.Request) {
utils.SendJSONResponse(w, string(js))
}
// Handle cert remove
func handleCertRemove(w http.ResponseWriter, r *http.Request) {
func handleSetDomainPreferredCertificate(w http.ResponseWriter, r *http.Request) {
//Get the domain
domain, err := utils.PostPara(r, "domain")
if err != nil {
utils.SendErrorResponse(w, "invalid domain given")
return
}
err = tlsCertManager.RemoveCert(domain)
//Get the certificate name
certName, err := utils.PostPara(r, "certname")
if err != nil {
utils.SendErrorResponse(w, err.Error())
utils.SendErrorResponse(w, "invalid certificate name given")
return
}
//Load the target endpoint
ept, err := dynamicProxyRouter.GetProxyEndpointById(domain, true)
if err != nil {
utils.SendErrorResponse(w, "proxy rule not found for domain: "+domain)
return
}
//Set the preferred certificate for the domain
err = dynamicProxyRouter.SetPreferredCertificateForDomain(ept, domain, certName)
if err != nil {
utils.SendErrorResponse(w, "failed to set preferred certificate: "+err.Error())
return
}
err = SaveReverseProxyConfig(ept)
if err != nil {
utils.SendErrorResponse(w, "failed to save reverse proxy config: "+err.Error())
return
}
utils.SendOK(w)
}

View File

@@ -108,9 +108,9 @@ func filterProxyConfigFilename(filename string) string {
func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error {
//Get filename for saving
filename := filepath.Join("./conf/proxy/", endpoint.RootOrMatchingDomain+".config")
filename := filepath.Join(CONF_HTTP_PROXY, endpoint.RootOrMatchingDomain+".config")
if endpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
filename = "./conf/proxy/root.config"
filename = filepath.Join(CONF_HTTP_PROXY, "root.config")
}
filename = filterProxyConfigFilename(filename)
@@ -125,9 +125,9 @@ func SaveReverseProxyConfig(endpoint *dynamicproxy.ProxyEndpoint) error {
}
func RemoveReverseProxyConfig(endpoint string) error {
filename := filepath.Join("./conf/proxy/", endpoint+".config")
filename := filepath.Join(CONF_HTTP_PROXY, endpoint+".config")
if endpoint == "/" {
filename = "./conf/proxy/root.config"
filename = filepath.Join(CONF_HTTP_PROXY, "/root.config")
}
filename = filterProxyConfigFilename(filename)
@@ -179,11 +179,11 @@ func ExportConfigAsZip(w http.ResponseWriter, r *http.Request) {
}
// Specify the folder path to be zipped
if !utils.FileExists("./conf") {
if !utils.FileExists(CONF_FOLDER) {
SystemWideLogger.PrintAndLog("Backup", "Configuration folder not found", nil)
return
}
folderPath := "./conf"
folderPath := CONF_FOLDER
// Set the Content-Type header to indicate it's a zip file
w.Header().Set("Content-Type", "application/zip")
@@ -284,12 +284,12 @@ func ImportConfigFromZip(w http.ResponseWriter, r *http.Request) {
return
}
// Create the target directory to unzip the files
targetDir := "./conf"
targetDir := CONF_FOLDER
if utils.FileExists(targetDir) {
//Backup the old config to old
//backupPath := filepath.Dir(*path_conf) + filepath.Base(*path_conf) + ".old_" + strconv.Itoa(int(time.Now().Unix()))
//os.Rename(*path_conf, backupPath)
os.Rename("./conf", "./conf.old_"+strconv.Itoa(int(time.Now().Unix())))
os.Rename(CONF_FOLDER, CONF_FOLDER+".old_"+strconv.Itoa(int(time.Now().Unix())))
}
err = os.MkdirAll(targetDir, os.ModePerm)

View File

@@ -44,7 +44,7 @@ import (
const (
/* Build Constants */
SYSTEM_NAME = "Zoraxy"
SYSTEM_VERSION = "3.2.4"
SYSTEM_VERSION = "3.2.6"
DEVELOPMENT_BUILD = false
/* System Constants */
@@ -63,14 +63,19 @@ const (
LOG_EXTENSION = ".log"
STATISTIC_AUTO_SAVE_INTERVAL = 600 /* Seconds */
/* Configuration Folder Storage Path Constants */
CONF_HTTP_PROXY = "./conf/proxy"
CONF_STREAM_PROXY = "./conf/streamproxy"
CONF_CERT_STORE = "./conf/certs"
CONF_REDIRECTION = "./conf/redirect"
CONF_ACCESS_RULE = "./conf/access"
CONF_PATH_RULE = "./conf/rules/pathrules"
CONF_PLUGIN_GROUPS = "./conf/plugin_groups.json"
/*
Configuration Folder Storage Path Constants
Note: No tailing slash in the path
*/
CONF_FOLDER = "./conf"
CONF_HTTP_PROXY = CONF_FOLDER + "/proxy"
CONF_STREAM_PROXY = CONF_FOLDER + "/streamproxy"
CONF_CERT_STORE = CONF_FOLDER + "/certs"
CONF_REDIRECTION = CONF_FOLDER + "/redirect"
CONF_ACCESS_RULE = CONF_FOLDER + "/access"
CONF_PATH_RULE = CONF_FOLDER + "/rules/pathrules"
CONF_PLUGIN_GROUPS = CONF_FOLDER + "/plugin_groups.json"
CONF_GEODB_PATH = CONF_FOLDER + "/geodb"
)
/* System Startup Flags */
@@ -144,6 +149,9 @@ var (
loadBalancer *loadbalance.RouteManager //Global scope loadbalancer, store the state of the lb routing
pluginManager *plugins.Manager //Plugin manager for managing plugins
//Plugin auth related
pluginApiKeyManager *auth.APIKeyManager //API key manager for plugin authentication
//Authentication Provider
forwardAuthRouter *forward.AuthRouter // Forward Auth router for Authelia/Authentik/etc authentication
oauth2Router *oauth2.OAuth2Router //OAuth2Router router for OAuth2Router authentication

View File

@@ -69,7 +69,7 @@ func main() {
os.Exit(0)
}
if *geoDbUpdate {
geodb.DownloadGeoDBUpdate("./conf/geodb")
geodb.DownloadGeoDBUpdate(CONF_GEODB_PATH)
os.Exit(0)
}
@@ -115,6 +115,7 @@ func main() {
//Initiate management interface APIs
requireAuth = !(*noauth)
initAPIs(webminPanelMux)
initRestAPI(webminPanelMux)
//Start the reverse proxy server in go routine
go func() {

View File

@@ -0,0 +1,112 @@
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"sync"
"time"
"imuslab.com/zoraxy/mod/plugins/zoraxy_plugin"
)
// PluginAPIKey represents an API key for a plugin
type PluginAPIKey struct {
PluginID string
APIKey string
PermittedEndpoints []zoraxy_plugin.PermittedAPIEndpoint // List of permitted API endpoints
CreatedAt time.Time
}
// APIKeyManager manages API keys for plugins
type APIKeyManager struct {
keys map[string]*PluginAPIKey // key: API key, value: plugin info
mutex sync.RWMutex
}
// NewAPIKeyManager creates a new API key manager
func NewAPIKeyManager() *APIKeyManager {
return &APIKeyManager{
keys: make(map[string]*PluginAPIKey),
mutex: sync.RWMutex{},
}
}
// GenerateAPIKey generates a new API key for a plugin
func (m *APIKeyManager) GenerateAPIKey(pluginID string, permittedEndpoints []zoraxy_plugin.PermittedAPIEndpoint) (*PluginAPIKey, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
// Generate a cryptographically secure random key
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
}
// Hash the random bytes to create the API key
hash := sha256.Sum256(bytes)
apiKey := hex.EncodeToString(hash[:])
// Create the plugin API key
pluginAPIKey := &PluginAPIKey{
PluginID: pluginID,
APIKey: apiKey,
PermittedEndpoints: permittedEndpoints,
CreatedAt: time.Now(),
}
// Store the API key
m.keys[apiKey] = pluginAPIKey
return pluginAPIKey, nil
}
// ValidateAPIKey validates an API key and returns the associated plugin information
func (m *APIKeyManager) ValidateAPIKey(apiKey string) (*PluginAPIKey, error) {
m.mutex.RLock()
defer m.mutex.RUnlock()
pluginAPIKey, exists := m.keys[apiKey]
if !exists {
return nil, fmt.Errorf("invalid API key")
}
return pluginAPIKey, nil
}
// ValidateAPIKeyForEndpoint validates an API key for a specific endpoint
func (m *APIKeyManager) ValidateAPIKeyForEndpoint(endpoint string, method string, apiKey string) (*PluginAPIKey, error) {
pluginAPIKey, err := m.ValidateAPIKey(apiKey)
if err != nil {
return nil, err
}
// Check if the endpoint is permitted
for _, permittedEndpoint := range pluginAPIKey.PermittedEndpoints {
if permittedEndpoint.Endpoint == endpoint && permittedEndpoint.Method == method {
return pluginAPIKey, nil
}
}
return nil, fmt.Errorf("endpoint not permitted for this API key")
}
// RevokeAPIKeysForPlugin revokes all API keys for a specific plugin
func (m *APIKeyManager) RevokeAPIKeysForPlugin(pluginID string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
keysToRemove := []string{}
for apiKey, pluginAPIKey := range m.keys {
if pluginAPIKey.PluginID == pluginID {
keysToRemove = append(keysToRemove, apiKey)
}
}
for _, apiKey := range keysToRemove {
delete(m.keys, apiKey)
}
return nil
}

View File

@@ -0,0 +1,94 @@
// Handles the API-Key based authentication for plugins
package auth
import (
"errors"
"fmt"
"net/http"
"strings"
)
const (
PLUGIN_API_PREFIX = "/plugin"
)
type PluginMiddlewareOptions struct {
DeniedHandler http.HandlerFunc //Thing(s) to do when request is rejected
ApiKeyManager *APIKeyManager
TargetMux *http.ServeMux
}
// PluginAuthMiddleware provides authentication middleware for plugin API requests
type PluginAuthMiddleware struct {
option PluginMiddlewareOptions
endpoints map[string]http.HandlerFunc
}
// NewPluginAuthMiddleware creates a new plugin authentication middleware
func NewPluginAuthMiddleware(option PluginMiddlewareOptions) *PluginAuthMiddleware {
return &PluginAuthMiddleware{
option: option,
endpoints: make(map[string]http.HandlerFunc),
}
}
func (m *PluginAuthMiddleware) HandleAuthCheck(w http.ResponseWriter, r *http.Request, handler http.HandlerFunc) {
// Check for API key in the Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// No authorization header
m.option.DeniedHandler(w, r)
return
}
// Check if it's a plugin API key (Bearer token)
if !strings.HasPrefix(authHeader, "Bearer ") {
// Not a Bearer token
m.option.DeniedHandler(w, r)
return
}
// Extract the API key
apiKey := strings.TrimPrefix(authHeader, "Bearer ")
// Validate the API key for this endpoint
_, err := m.option.ApiKeyManager.ValidateAPIKeyForEndpoint(r.URL.Path, r.Method, apiKey)
if err != nil {
// Invalid API key or endpoint not permitted
m.option.DeniedHandler(w, r)
return
}
// Call the original handler
handler(w, r)
}
// wraps an HTTP handler with plugin authentication middleware
func (m *PluginAuthMiddleware) HandleFunc(endpoint string, handler http.HandlerFunc) error {
// ensure the endpoint is prefixed with PLUGIN_API_PREFIX
if !strings.HasPrefix(endpoint, PLUGIN_API_PREFIX) {
endpoint = PLUGIN_API_PREFIX + endpoint
}
// Check if the endpoint already registered
if _, exist := m.endpoints[endpoint]; exist {
fmt.Println("WARNING! Duplicated registering of plugin api endpoint: " + endpoint)
return errors.New("endpoint register duplicated")
}
m.endpoints[endpoint] = handler
wrappedHandler := func(w http.ResponseWriter, r *http.Request) {
m.HandleAuthCheck(w, r, handler)
}
// Ok. Register handler
if m.option.TargetMux == nil {
http.HandleFunc(endpoint, wrappedHandler)
} else {
m.option.TargetMux.HandleFunc(endpoint, wrappedHandler)
}
return nil
}

View File

@@ -58,11 +58,20 @@ func NewAuthRouter(options *AuthRouterOptions) *AuthRouter {
options.Database.Read(DatabaseTable, DatabaseKeyRequestIncludedCookies, &requestIncludedCookies)
options.Database.Read(DatabaseTable, DatabaseKeyRequestExcludedCookies, &requestExcludedCookies)
options.ResponseHeaders = strings.Split(responseHeaders, ",")
options.ResponseClientHeaders = strings.Split(responseClientHeaders, ",")
options.RequestHeaders = strings.Split(requestHeaders, ",")
options.RequestIncludedCookies = strings.Split(requestIncludedCookies, ",")
options.RequestExcludedCookies = strings.Split(requestExcludedCookies, ",")
// Helper function to clean empty strings from split results
cleanSplit := func(s string) []string {
if s == "" {
return nil
}
return strings.Split(s, ",")
}
options.ResponseHeaders = cleanSplit(responseHeaders)
options.ResponseClientHeaders = cleanSplit(responseClientHeaders)
options.RequestHeaders = cleanSplit(requestHeaders)
options.RequestIncludedCookies = cleanSplit(requestIncludedCookies)
options.RequestExcludedCookies = cleanSplit(requestExcludedCookies)
return &AuthRouter{
client: &http.Client{

View File

@@ -61,7 +61,7 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
hostPath := strings.Split(r.Host, ":")
domainOnly = hostPath[0]
}
sep := h.Parent.getProxyEndpointFromHostname(domainOnly)
sep := h.Parent.GetProxyEndpointFromHostname(domainOnly)
if sep != nil && !sep.Disabled {
//Matching proxy rule found
//Access Check (blacklist / whitelist)

View File

@@ -0,0 +1,59 @@
package dynamicproxy
import (
"encoding/json"
"errors"
"fmt"
"imuslab.com/zoraxy/mod/tlscert"
)
func (router *Router) ResolveHostSpecificTlsBehaviorForHostname(hostname string) (*tlscert.HostSpecificTlsBehavior, error) {
if hostname == "" {
return nil, errors.New("hostname cannot be empty")
}
ept := router.GetProxyEndpointFromHostname(hostname)
if ept == nil {
return tlscert.GetDefaultHostSpecificTlsBehavior(), nil
}
// Check if the endpoint has a specific TLS behavior
if ept.TlsOptions != nil {
imported := &tlscert.HostSpecificTlsBehavior{}
router.tlsBehaviorMutex.RLock()
// Deep copy the TlsOptions using JSON marshal/unmarshal
data, err := json.Marshal(ept.TlsOptions)
if err != nil {
router.tlsBehaviorMutex.RUnlock()
return nil, fmt.Errorf("failed to deepcopy TlsOptions: %w", err)
}
router.tlsBehaviorMutex.RUnlock()
if err := json.Unmarshal(data, imported); err != nil {
return nil, fmt.Errorf("failed to deepcopy TlsOptions: %w", err)
}
return imported, nil
}
return tlscert.GetDefaultHostSpecificTlsBehavior(), nil
}
func (router *Router) SetPreferredCertificateForDomain(ept *ProxyEndpoint, domain string, certName string) error {
if ept == nil || certName == "" {
return errors.New("endpoint and certificate name cannot be empty")
}
// Set the preferred certificate for the endpoint
if ept.TlsOptions == nil {
ept.TlsOptions = tlscert.GetDefaultHostSpecificTlsBehavior()
}
router.tlsBehaviorMutex.Lock()
if ept.TlsOptions.PreferredCertificate == nil {
ept.TlsOptions.PreferredCertificate = make(map[string]string)
}
ept.TlsOptions.PreferredCertificate[domain] = certName
router.tlsBehaviorMutex.Unlock()
return nil
}

View File

@@ -111,7 +111,7 @@ func (router *Router) StartProxyService() error {
hostPath := strings.Split(r.Host, ":")
domainOnly = hostPath[0]
}
sep := router.getProxyEndpointFromHostname(domainOnly)
sep := router.GetProxyEndpointFromHostname(domainOnly)
if sep != nil && sep.BypassGlobalTLS {
//Allow routing via non-TLS handler
originalHostHeader := r.Host
@@ -335,7 +335,7 @@ func (router *Router) IsProxiedSubdomain(r *http.Request) bool {
hostname = r.Host
}
hostname = strings.Split(hostname, ":")[0]
subdEndpoint := router.getProxyEndpointFromHostname(hostname)
subdEndpoint := router.GetProxyEndpointFromHostname(hostname)
return subdEndpoint != nil
}

View File

@@ -34,7 +34,7 @@ func (router *Router) getTargetProxyEndpointFromRequestURI(requestURI string) *P
}
// Get the proxy endpoint from hostname, which might includes checking of wildcard certificates
func (router *Router) getProxyEndpointFromHostname(hostname string) *ProxyEndpoint {
func (router *Router) GetProxyEndpointFromHostname(hostname string) *ProxyEndpoint {
var targetSubdomainEndpoint *ProxyEndpoint = nil
hostname = strings.ToLower(hostname)
ep, ok := router.ProxyEndpoints.Load(hostname)
@@ -63,7 +63,7 @@ func (router *Router) getProxyEndpointFromHostname(hostname string) *ProxyEndpoi
}
//Wildcard not match. Check for alias
if ep.MatchingDomainAlias != nil && len(ep.MatchingDomainAlias) > 0 {
if len(ep.MatchingDomainAlias) > 0 {
for _, aliasDomain := range ep.MatchingDomainAlias {
match, err := filepath.Match(aliasDomain, hostname)
if err != nil {

View File

@@ -75,16 +75,20 @@ type RouterOption struct {
/* Router Object */
type Router struct {
Option *RouterOption
ProxyEndpoints *sync.Map //Map of ProxyEndpoint objects, each ProxyEndpoint object is a routing rule that handle incoming requests
Running bool //If the router is running
Root *ProxyEndpoint //Root proxy endpoint, default site
mux http.Handler //HTTP handler
server *http.Server //HTTP server
tlsListener net.Listener //TLS listener, handle SNI routing
loadBalancer *loadbalance.RouteManager //Load balancer routing manager
routingRules []*RoutingRule //Special routing rules, handle high priority routing like ACME request handling
ProxyEndpoints *sync.Map //Map of ProxyEndpoint objects, each ProxyEndpoint object is a routing rule that handle incoming requests
Running bool //If the router is running
Root *ProxyEndpoint //Root proxy endpoint, default site
/* Internals */
mux http.Handler //HTTP handler
server *http.Server //HTTP server
loadBalancer *loadbalance.RouteManager //Load balancer routing manager
routingRules []*RoutingRule //Special routing rules, handle high priority routing like ACME request handling
tlsListener net.Listener //TLS listener, handle SNI routing
tlsBehaviorMutex sync.RWMutex //Mutex for tlsBehavior map
tlsRedirectStop chan bool //Stop channel for tls redirection server
tlsRedirectStop chan bool //Stop channel for tls redirection server
rateLimterStop chan bool //Stop channel for rate limiter
rateLimitCounter RequestCountPerIpTable //Request counter for rate limter
}

View File

@@ -41,6 +41,18 @@ func (m *Manager) StartPlugin(pluginID string) error {
Port: getRandomPortNumber(),
RuntimeConst: *m.Options.SystemConst,
}
// Generate API key if the plugin has permitted endpoints
if len(thisPlugin.Spec.PermittedAPIEndpoints) > 0 {
apiKey, err := m.Options.APIKeyManager.GenerateAPIKey(thisPlugin.Spec.ID, thisPlugin.Spec.PermittedAPIEndpoints)
if err != nil {
return err
}
pluginConfiguration.APIKey = apiKey.APIKey
pluginConfiguration.ZoraxyPort = m.Options.ZoraxyPort
m.Log("Generated API key for plugin "+thisPlugin.Spec.Name, nil)
}
js, _ := json.Marshal(pluginConfiguration)
//Start the plugin with given configuration
@@ -270,6 +282,13 @@ func (m *Manager) StopPlugin(pluginID string) error {
thisPlugin.Enabled = false
thisPlugin.StopAllStaticPathRouters()
thisPlugin.StopDynamicForwardRouter()
//Clean up API key
err = m.Options.APIKeyManager.RevokeAPIKeysForPlugin(thisPlugin.Spec.ID)
if err != nil {
m.Log("Failed to revoke API keys for plugin "+thisPlugin.Spec.Name, err)
}
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"sync"
"time"
"imuslab.com/zoraxy/mod/auth"
"imuslab.com/zoraxy/mod/database"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/info/logger"
@@ -42,10 +43,14 @@ type ManagerOptions struct {
/* Runtime */
SystemConst *zoraxyPlugin.RuntimeConstantValue //The system constant value
ZoraxyPort int //The port of the Zoraxy instance, used for API calls
CSRFTokenGen func(*http.Request) string `json:"-"` //The CSRF token generator function
Database *database.Database `json:"-"`
Logger *logger.Logger `json:"-"`
/* API Key Management */
APIKeyManager *auth.APIKeyManager `json:"-"` //The API key manager for the plugins
/* Development */
EnableHotReload bool //Check if the plugin file is changed and reload the plugin automatically
HotReloadInterval int //The interval for checking the plugin file change, in seconds

View File

@@ -17,7 +17,7 @@ import (
type SniffResult int
const (
SniffResultAccpet SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultAccept SniffResult = iota // Forward the request to this plugin dynamic capture ingress
SniffResultSkip // Skip this plugin and let the next plugin handle the request
)
@@ -62,7 +62,7 @@ func (p *PathRouter) RegisterDynamicSniffHandler(sniff_ingress string, mux *http
payload.rawRequest = r
sniffResult := handler(&payload)
if sniffResult == SniffResultAccpet {
if sniffResult == SniffResultAccept {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
} else {

View File

@@ -47,6 +47,12 @@ type RuntimeConstantValue struct {
DevelopmentBuild bool `json:"development_build"` //Whether the Zoraxy is a development build or not
}
type PermittedAPIEndpoint struct {
Method string `json:"method"` //HTTP method for the API endpoint (e.g., GET, POST)
Endpoint string `json:"endpoint"` //The API endpoint that the plugin can access
Reason string `json:"reason"` //The reason why the plugin needs to access this endpoint
}
/*
IntroSpect Payload
@@ -97,7 +103,10 @@ type IntroSpect struct {
/* Subscriptions Settings */
SubscriptionPath string `json:"subscription_path"` //Subscription event path of your plugin (e.g. /notifyme), a POST request with SubscriptionEvent as body will be sent to this path when the event is triggered
SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, see Zoraxy documentation for more details
SubscriptionsEvents map[string]string `json:"subscriptions_events"` //Subscriptions events of your plugin, paired with comments describing how the event is used, see Zoraxy documentation for more details
/* API Access Control */
PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"` //List of API endpoints this plugin can access, and a description of why the plugin needs to access this endpoint
}
/*
@@ -126,8 +135,10 @@ by the supplied values like starting a web server at given port
that listens to 127.0.0.1:port
*/
type ConfigureSpec struct {
Port int `json:"port"` //Port to listen
RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
Port int `json:"port"` //Port to listen
RuntimeConst RuntimeConstantValue `json:"runtime_const"` //Runtime constant values
APIKey string `json:"api_key,omitempty"` //API key for accessing Zoraxy APIs, if the plugin has permitted endpoints
ZoraxyPort int `json:"zoraxy_port,omitempty"` //The port that Zoraxy is running on, used for making API calls to Zoraxy
//To be expanded
}

View File

@@ -0,0 +1,93 @@
package tlscert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"time"
)
// GenerateSelfSignedCertificate generates a self-signed ECDSA certificate and saves it to the specified files.
func (m *Manager) GenerateSelfSignedCertificate(cn string, sans []string, certFile string, keyFile string) error {
// Generate private key (ECDSA P-256)
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to generate private key", err)
return err
}
// Create certificate template
template := x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{
CommonName: cn, // Common Name for the certificate
Organization: []string{"aroz.org"}, // Organization name
OrganizationalUnit: []string{"Zoraxy"}, // Organizational Unit
Country: []string{"US"}, // Country code
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour), // valid for 1 year
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: sans, // Subject Alternative Names
}
// Create self-signed certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to create certificate", err)
return err
}
// Remove old certificate file if it exists
certPath := filepath.Join(m.CertStore, certFile)
if _, err := os.Stat(certPath); err == nil {
os.Remove(certPath)
}
// Remove old key file if it exists
keyPath := filepath.Join(m.CertStore, keyFile)
if _, err := os.Stat(keyPath); err == nil {
os.Remove(keyPath)
}
// Write certificate to file
certOut, err := os.Create(filepath.Join(m.CertStore, certFile))
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to open cert file for writing: "+certFile, err)
return err
}
defer certOut.Close()
err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to write certificate to file: "+certFile, err)
return err
}
// Encode private key to PEM
privBytes, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
m.Logger.PrintAndLog("tls-router", "Unable to marshal ECDSA private key", err)
return err
}
keyOut, err := os.Create(filepath.Join(m.CertStore, keyFile))
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to open key file for writing: "+keyFile, err)
return err
}
defer keyOut.Close()
err = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to write private key to file: "+keyFile, err)
return err
}
m.Logger.PrintAndLog("tls-router", "Certificate and key generated: "+certFile+", "+keyFile, nil)
return nil
}

352
src/mod/tlscert/handler.go Normal file
View File

@@ -0,0 +1,352 @@
package tlscert
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"imuslab.com/zoraxy/mod/acme"
"imuslab.com/zoraxy/mod/utils"
)
// Handle cert remove
func (m *Manager) HandleCertRemove(w http.ResponseWriter, r *http.Request) {
domain, err := utils.PostPara(r, "domain")
if err != nil {
utils.SendErrorResponse(w, "invalid domain given")
return
}
err = m.RemoveCert(domain)
if err != nil {
utils.SendErrorResponse(w, err.Error())
}
}
// Handle download of the selected certificate
func (m *Manager) HandleCertDownload(w http.ResponseWriter, r *http.Request) {
// get the certificate name
certname, err := utils.GetPara(r, "certname")
if err != nil {
utils.SendErrorResponse(w, "invalid certname given")
return
}
certname = filepath.Base(certname) //prevent path escape
// check if the cert exists
pubKey := filepath.Join(filepath.Join(m.CertStore), certname+".key")
priKey := filepath.Join(filepath.Join(m.CertStore), certname+".pem")
if utils.FileExists(pubKey) && utils.FileExists(priKey) {
//Zip them and serve them via http download
seeking, _ := utils.GetBool(r, "seek")
if seeking {
//This request only check if the key exists. Do not provide download
utils.SendOK(w)
return
}
//Serve both file in zip
zipTmpFolder := "./tmp/download"
os.MkdirAll(zipTmpFolder, 0775)
zipFileName := filepath.Join(zipTmpFolder, certname+".zip")
err := utils.ZipFiles(zipFileName, pubKey, priKey)
if err != nil {
http.Error(w, "Failed to create zip file", http.StatusInternalServerError)
return
}
defer os.Remove(zipFileName) // Clean up the zip file after serving
// Serve the zip file
w.Header().Set("Content-Disposition", "attachment; filename=\""+certname+"_export.zip\"")
w.Header().Set("Content-Type", "application/zip")
http.ServeFile(w, r, zipFileName)
} else {
//Not both key exists
utils.SendErrorResponse(w, "invalid key-pairs: private key or public key not found in key store")
return
}
}
// Handle upload of the certificate
func (m *Manager) HandleCertUpload(w http.ResponseWriter, r *http.Request) {
// check if request method is POST
if r.Method != "POST" {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// get the key type
keytype, err := utils.GetPara(r, "ktype")
overWriteFilename := ""
if err != nil {
http.Error(w, "Not defined key type (pub / pri)", http.StatusBadRequest)
return
}
// get the domain
domain, err := utils.GetPara(r, "domain")
if err != nil {
//Assume localhost
domain = "default"
}
switch keytype {
case "pub":
overWriteFilename = domain + ".pem"
case "pri":
overWriteFilename = domain + ".key"
default:
http.Error(w, "Not supported keytype: "+keytype, http.StatusBadRequest)
return
}
// parse multipart form data
err = r.ParseMultipartForm(10 << 20) // 10 MB
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
// get file from form data
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "Failed to get file", http.StatusBadRequest)
return
}
defer file.Close()
// create file in upload directory
os.MkdirAll(m.CertStore, 0775)
f, err := os.Create(filepath.Join(m.CertStore, overWriteFilename))
if err != nil {
http.Error(w, "Failed to create file", http.StatusInternalServerError)
return
}
defer f.Close()
// copy file contents to destination file
_, err = io.Copy(f, file)
if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError)
return
}
//Update cert list
m.UpdateLoadedCertList()
// send response
fmt.Fprintln(w, "File upload successful!")
}
// List all certificates and map all their domains to the cert filename
func (m *Manager) HandleListDomains(w http.ResponseWriter, r *http.Request) {
filenames, err := os.ReadDir(m.CertStore)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
certnameToDomainMap := map[string]string{}
for _, filename := range filenames {
if filename.IsDir() {
continue
}
certFilepath := filepath.Join(m.CertStore, filename.Name())
certBtyes, err := os.ReadFile(certFilepath)
if err != nil {
// Unable to load this file
m.Logger.PrintAndLog("TLS", "Unable to load certificate: "+certFilepath, err)
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))
}
// Return a list of domains where the certificates covers
func (m *Manager) HandleListCertificate(w http.ResponseWriter, r *http.Request) {
filenames, err := m.ListCertDomains()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
showDate, _ := utils.GetBool(r, "date")
if showDate {
type CertInfo struct {
Domain string
LastModifiedDate string
ExpireDate string
RemainingDays int
UseDNS bool
}
results := []*CertInfo{}
for _, filename := range filenames {
certFilepath := filepath.Join(m.CertStore, filename+".pem")
//keyFilepath := filepath.Join(tlsCertManager.CertStore, filename+".key")
fileInfo, err := os.Stat(certFilepath)
if err != nil {
utils.SendErrorResponse(w, "invalid domain certificate discovered: "+filename)
return
}
modifiedTime := fileInfo.ModTime().Format("2006-01-02 15:04:05")
certExpireTime := "Unknown"
certBtyes, err := os.ReadFile(certFilepath)
expiredIn := 0
if err != nil {
//Unable to load this file
continue
} else {
//Cert loaded. Check its expire time
block, _ := pem.Decode(certBtyes)
if block != nil {
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)
}
}
}
certInfoFilename := filepath.Join(m.CertStore, filename+".json")
useDNSValidation := false //Default to false for HTTP TLS certificates
certInfo, err := acme.LoadCertInfoJSON(certInfoFilename) //Note: Not all certs have info json
if err == nil {
useDNSValidation = certInfo.UseDNS
}
thisCertInfo := CertInfo{
Domain: filename,
LastModifiedDate: modifiedTime,
ExpireDate: certExpireTime,
RemainingDays: expiredIn,
UseDNS: useDNSValidation,
}
results = append(results, &thisCertInfo)
}
// convert ExpireDate to date object and sort asc
sort.Slice(results, func(i, j int) bool {
date1, _ := time.Parse("2006-01-02 15:04:05", results[i].ExpireDate)
date2, _ := time.Parse("2006-01-02 15:04:05", results[j].ExpireDate)
return date1.Before(date2)
})
js, _ := json.Marshal(results)
w.Header().Set("Content-Type", "application/json")
w.Write(js)
return
}
response, err := json.Marshal(filenames)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(response)
}
// Check if the default certificates is correctly setup
func (m *Manager) HandleDefaultCertCheck(w http.ResponseWriter, r *http.Request) {
type CheckResult struct {
DefaultPubExists bool
DefaultPriExists bool
}
pub, pri := m.DefaultCertExistsSep()
js, _ := json.Marshal(CheckResult{
pub,
pri,
})
utils.SendJSONResponse(w, string(js))
}
func (m *Manager) HandleSelfSignCertGenerate(w http.ResponseWriter, r *http.Request) {
// Get the common name from the request
cn, err := utils.GetPara(r, "cn")
if err != nil {
utils.SendErrorResponse(w, "Common name not provided")
return
}
domains, err := utils.PostPara(r, "domains")
if err != nil {
//No alias domains provided, use the common name as the only domain
domains = "[]"
}
SANs := []string{}
if err := json.Unmarshal([]byte(domains), &SANs); err != nil {
utils.SendErrorResponse(w, "Invalid domains format: "+err.Error())
return
}
//SANs = append([]string{cn}, SANs...)
priKeyFilename := domainToFilename(cn, ".key")
pubKeyFilename := domainToFilename(cn, ".pem")
// Generate self-signed certificate
err = m.GenerateSelfSignedCertificate(cn, SANs, pubKeyFilename, priKeyFilename)
if err != nil {
utils.SendErrorResponse(w, "Failed to generate self-signed certificate: "+err.Error())
return
}
//Update the certificate store
err = m.UpdateLoadedCertList()
if err != nil {
utils.SendErrorResponse(w, "Failed to update certificate store: "+err.Error())
return
}
utils.SendOK(w)
}

View File

@@ -43,3 +43,30 @@ func matchClosestDomainCertificate(subdomain string, domains []string) string {
return matchingDomain
}
// Convert a domain name to a filename format
func domainToFilename(domain string, ext string) string {
// Replace wildcard '*' with '_'
domain = strings.TrimSpace(domain)
if strings.HasPrefix(domain, "*") {
domain = "_" + strings.TrimPrefix(domain, "*")
}
// Add .pem extension
ext = strings.TrimPrefix(ext, ".") // Ensure ext does not start with a dot
return domain + "." + ext
}
func filenameToDomain(filename string) string {
// Remove the extension
ext := filepath.Ext(filename)
if ext != "" {
filename = strings.TrimSuffix(filename, ext)
}
if strings.HasPrefix(filename, "_") {
filename = "*" + filename[1:]
}
return filename
}

View File

@@ -21,10 +21,10 @@ type CertCache struct {
}
type HostSpecificTlsBehavior struct {
DisableSNI bool //If SNI is enabled for this server name
DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name
EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name
PreferredCertificate string //Preferred certificate for this server name, if empty, use the first matching certificate
DisableSNI bool //If SNI is enabled for this server name
DisableLegacyCertificateMatching bool //If legacy certificate matching is disabled for this server name
EnableAutoHTTPS bool //If auto HTTPS is enabled for this server name
PreferredCertificate map[string]string //Preferred certificate for this server name, if empty, use the first matching certificate
}
type Manager struct {
@@ -34,13 +34,12 @@ type Manager struct {
/* External handlers */
hostSpecificTlsBehavior func(serverName string) (*HostSpecificTlsBehavior, error) // Function to get host specific TLS behavior, if nil, use global TLS options
verbal bool
}
//go:embed localhost.pem localhost.key
var buildinCertStore embed.FS
func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager, error) {
func NewManager(certStore string, logger *logger.Logger) (*Manager, error) {
if !utils.FileExists(certStore) {
os.MkdirAll(certStore, 0775)
}
@@ -63,7 +62,6 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager,
CertStore: certStore,
LoadedCerts: []*CertCache{},
hostSpecificTlsBehavior: defaultHostSpecificTlsBehavior, //Default to no SNI and no auto HTTPS
verbal: verbal,
Logger: logger,
}
@@ -82,7 +80,7 @@ func GetDefaultHostSpecificTlsBehavior() *HostSpecificTlsBehavior {
DisableSNI: false,
DisableLegacyCertificateMatching: false,
EnableAutoHTTPS: false,
PreferredCertificate: "",
PreferredCertificate: map[string]string{}, // No preferred certificate, use the first matching certificate
}
}
@@ -90,6 +88,10 @@ func defaultHostSpecificTlsBehavior(serverName string) (*HostSpecificTlsBehavior
return GetDefaultHostSpecificTlsBehavior(), nil
}
func (m *Manager) SetHostSpecificTlsBehavior(fn func(serverName string) (*HostSpecificTlsBehavior, error)) {
m.hostSpecificTlsBehavior = fn
}
// Update domain mapping from file
func (m *Manager) UpdateLoadedCertList() error {
//Get a list of certificates from file
@@ -213,13 +215,17 @@ func (m *Manager) GetCertificateByHostname(hostname string) (string, string, err
if err != nil {
tlsBehavior, _ = defaultHostSpecificTlsBehavior(hostname)
}
preferredCertificate, ok := tlsBehavior.PreferredCertificate[hostname]
if !ok {
preferredCertificate = ""
}
if tlsBehavior.DisableSNI && tlsBehavior.PreferredCertificate != "" &&
utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem")) &&
utils.FileExists(filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key")) {
if tlsBehavior.DisableSNI && preferredCertificate != "" &&
utils.FileExists(filepath.Join(m.CertStore, preferredCertificate+".pem")) &&
utils.FileExists(filepath.Join(m.CertStore, preferredCertificate+".key")) {
//User setup a Preferred certificate, use the preferred certificate directly
pubKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".pem")
priKey = filepath.Join(m.CertStore, tlsBehavior.PreferredCertificate+".key")
pubKey = filepath.Join(m.CertStore, preferredCertificate+".pem")
priKey = filepath.Join(m.CertStore, preferredCertificate+".key")
} else {
if !tlsBehavior.DisableLegacyCertificateMatching &&
utils.FileExists(filepath.Join(m.CertStore, hostname+".pem")) &&

135
src/plugin_api.go Normal file
View File

@@ -0,0 +1,135 @@
package main
import (
"net/http"
"imuslab.com/zoraxy/mod/auth"
"imuslab.com/zoraxy/mod/netstat"
)
// Register the APIs for HTTP proxy management functions
func RegisterHTTPProxyRestAPI(authMiddleware *auth.PluginAuthMiddleware) {
/* Reverse Proxy Settings & Status */
authMiddleware.HandleFunc("/api/proxy/status", ReverseProxyStatus)
authMiddleware.HandleFunc("/api/proxy/list", ReverseProxyList)
authMiddleware.HandleFunc("/api/proxy/listTags", ReverseProxyListTags)
authMiddleware.HandleFunc("/api/proxy/detail", ReverseProxyListDetail)
/* Reverse proxy upstream (load balance) */
authMiddleware.HandleFunc("/api/proxy/upstream/list", ReverseProxyUpstreamList)
/* Reverse proxy virtual directory */
authMiddleware.HandleFunc("/api/proxy/vdir/list", ReverseProxyListVdir)
/* Reverse proxy user-defined header */
authMiddleware.HandleFunc("/api/proxy/header/list", HandleCustomHeaderList)
/* Reverse proxy auth related */
authMiddleware.HandleFunc("/api/proxy/auth/exceptions/list", ListProxyBasicAuthExceptionPaths)
}
// Register the APIs for redirection rules management functions
func RegisterRedirectionRestAPI(authRouter *auth.PluginAuthMiddleware) {
authRouter.HandleFunc("/api/redirect/list", handleListRedirectionRules)
}
// Register the APIs for access rules management functions
func RegisterAccessRuleRestAPI(authRouter *auth.PluginAuthMiddleware) {
/* Access Rules Settings & Status */
authRouter.HandleFunc("/api/access/list", handleListAccessRules)
// authRouter.HandleFunc("/api/access/attach", handleAttachRuleToHost)
// authRouter.HandleFunc("/api/access/create", handleCreateAccessRule)
// authRouter.HandleFunc("/api/access/remove", handleRemoveAccessRule)
// authRouter.HandleFunc("/api/access/update", handleUpadateAccessRule)
/* Blacklist */
authRouter.HandleFunc("/api/blacklist/list", handleListBlacklisted)
authRouter.HandleFunc("/api/blacklist/country/add", handleCountryBlacklistAdd)
authRouter.HandleFunc("/api/blacklist/country/remove", handleCountryBlacklistRemove)
authRouter.HandleFunc("/api/blacklist/ip/add", handleIpBlacklistAdd)
authRouter.HandleFunc("/api/blacklist/ip/remove", handleIpBlacklistRemove)
authRouter.HandleFunc("/api/blacklist/enable", handleBlacklistEnable)
/* Whitelist */
authRouter.HandleFunc("/api/whitelist/list", handleListWhitelisted)
authRouter.HandleFunc("/api/whitelist/country/add", handleCountryWhitelistAdd)
authRouter.HandleFunc("/api/whitelist/country/remove", handleCountryWhitelistRemove)
authRouter.HandleFunc("/api/whitelist/ip/add", handleIpWhitelistAdd)
authRouter.HandleFunc("/api/whitelist/ip/remove", handleIpWhitelistRemove)
authRouter.HandleFunc("/api/whitelist/enable", handleWhitelistEnable)
authRouter.HandleFunc("/api/whitelist/allowLocal", handleWhitelistAllowLoopback)
/* Quick Ban List */
authRouter.HandleFunc("/api/quickban/list", handleListQuickBan)
}
// Register the APIs for path blocking rules management functions, WIP
func RegisterPathRuleRestAPI(authRouter *auth.PluginAuthMiddleware) {
authRouter.HandleFunc("/api/pathrule/list", pathRuleHandler.HandleListBlockingPath)
}
// Register the APIs statistic anlysis and uptime monitoring functions
func RegisterStatisticalRestAPI(authRouter *auth.PluginAuthMiddleware) {
/* Traffic Summary */
authRouter.HandleFunc("/api/stats/summary", statisticCollector.HandleTodayStatLoad)
authRouter.HandleFunc("/api/stats/countries", HandleCountryDistrSummary)
authRouter.HandleFunc("/api/stats/netstat", netstatBuffers.HandleGetNetworkInterfaceStats)
authRouter.HandleFunc("/api/stats/netstatgraph", netstatBuffers.HandleGetBufferedNetworkInterfaceStats)
authRouter.HandleFunc("/api/stats/listnic", netstat.HandleListNetworkInterfaces)
/* Zoraxy Analytic */
authRouter.HandleFunc("/api/analytic/list", AnalyticLoader.HandleSummaryList)
authRouter.HandleFunc("/api/analytic/load", AnalyticLoader.HandleLoadTargetDaySummary)
authRouter.HandleFunc("/api/analytic/loadRange", AnalyticLoader.HandleLoadTargetRangeSummary)
authRouter.HandleFunc("/api/analytic/exportRange", AnalyticLoader.HandleRangeExport)
authRouter.HandleFunc("/api/analytic/resetRange", AnalyticLoader.HandleRangeReset)
/* UpTime Monitor */
authRouter.HandleFunc("/api/utm/list", HandleUptimeMonitorListing)
}
// Register the APIs for Stream (TCP / UDP) Proxy management functions
func RegisterStreamProxyRestAPI(authRouter *auth.PluginAuthMiddleware) {
authRouter.HandleFunc("/api/streamprox/config/list", streamProxyManager.HandleListConfigs)
authRouter.HandleFunc("/api/streamprox/config/status", streamProxyManager.HandleGetProxyStatus)
}
// Register the APIs for mDNS service management functions
func RegisterMDNSRestAPI(authRouter *auth.PluginAuthMiddleware) {
authRouter.HandleFunc("/api/mdns/list", HandleMdnsListing)
}
// Register the APIs for Static Web Server management functions
func RegisterStaticWebServerRestAPI(authRouter *auth.PluginAuthMiddleware) {
/* Static Web Server Controls */
authRouter.HandleFunc("/api/webserv/status", staticWebServer.HandleGetStatus)
/* File Manager */
if *allowWebFileManager {
authRouter.HandleFunc("/api/fs/list", staticWebServer.FileManager.HandleList)
}
}
func RegisterPluginRestAPI(authRouter *auth.PluginAuthMiddleware) {
authRouter.HandleFunc("/api/plugins/list", pluginManager.HandleListPlugins)
authRouter.HandleFunc("/api/plugins/info", pluginManager.HandlePluginInfo)
authRouter.HandleFunc("/api/plugins/groups/list", pluginManager.HandleListPluginGroups)
authRouter.HandleFunc("/api/plugins/store/list", pluginManager.HandleListDownloadablePlugins)
}
/* Register all the APIs */
func initRestAPI(targetMux *http.ServeMux) {
authMiddleware := auth.NewPluginAuthMiddleware(
auth.PluginMiddlewareOptions{
TargetMux: targetMux,
ApiKeyManager: pluginApiKeyManager,
DeniedHandler: func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "401 - Unauthorized", http.StatusUnauthorized)
},
},
)
//Register the APIs
RegisterHTTPProxyRestAPI(authMiddleware)
RegisterRedirectionRestAPI(authMiddleware)
RegisterAccessRuleRestAPI(authMiddleware)
RegisterPathRuleRestAPI(authMiddleware)
RegisterStatisticalRestAPI(authMiddleware)
RegisterStreamProxyRestAPI(authMiddleware)
RegisterMDNSRestAPI(authMiddleware)
RegisterStaticWebServerRestAPI(authMiddleware)
RegisterPluginRestAPI(authMiddleware)
}

View File

@@ -135,12 +135,12 @@ func ReverseProxtInit() {
Load all conf from files
*/
confs, _ := filepath.Glob("./conf/proxy/*.config")
confs, _ := filepath.Glob(CONF_HTTP_PROXY + "/*.config")
for _, conf := range confs {
err := LoadReverseProxyConfig(conf)
if err != nil {
SystemWideLogger.PrintAndLog("proxy-config", "Failed to load config file: "+filepath.Base(conf), err)
return
continue
}
}
@@ -717,6 +717,11 @@ func ReverseProxyHandleSetTlsConfig(w http.ResponseWriter, r *http.Request) {
return
}
if newTlsConfig.PreferredCertificate == nil {
//No update needed, reuse the current TLS config
newTlsConfig.PreferredCertificate = ept.TlsOptions.PreferredCertificate
}
ept.TlsOptions = newTlsConfig
//Prepare to replace the current routing rule

View File

@@ -1,15 +1,17 @@
package main
import (
"imuslab.com/zoraxy/mod/auth/sso/oauth2"
"log"
"net/http"
"net/netip"
"os"
"runtime"
"strconv"
"strings"
"time"
"imuslab.com/zoraxy/mod/auth/sso/oauth2"
"github.com/gorilla/csrf"
"imuslab.com/zoraxy/mod/access"
"imuslab.com/zoraxy/mod/acme"
@@ -98,8 +100,11 @@ func startupSequence() {
http.Redirect(w, r, "/login.html", http.StatusTemporaryRedirect)
})
// Create an API key manager for plugin authentication
pluginApiKeyManager = auth.NewAPIKeyManager()
//Create a TLS certificate manager
tlsCertManager, err = tlscert.NewManager(CONF_CERT_STORE, *development_build, SystemWideLogger)
tlsCertManager, err = tlscert.NewManager(CONF_CERT_STORE, SystemWideLogger)
if err != nil {
panic(err)
}
@@ -312,11 +317,18 @@ func startupSequence() {
*/
pluginFolder := *path_plugin
pluginFolder = strings.TrimSuffix(pluginFolder, "/")
ZoraxyAddrPort, err := netip.ParseAddrPort(*webUIPort)
ZoraxyPort := 8000
if err == nil && ZoraxyAddrPort.IsValid() && ZoraxyAddrPort.Port() > 0 {
ZoraxyPort = int(ZoraxyAddrPort.Port())
}
pluginManager = plugins.NewPluginManager(&plugins.ManagerOptions{
PluginDir: pluginFolder,
Database: sysdb,
Logger: SystemWideLogger,
PluginGroupsConfig: CONF_PLUGIN_GROUPS,
APIKeyManager: pluginApiKeyManager,
ZoraxyPort: ZoraxyPort,
CSRFTokenGen: func(r *http.Request) string {
return csrf.Token(r)
},
@@ -366,6 +378,9 @@ func finalSequence() {
//Inject routing rules
registerBuildInRoutingRules()
//Set the host specific TLS behavior resolver for resolving TLS behavior for each hostname
tlsCertManager.SetHostSpecificTlsBehavior(dynamicProxyRouter.ResolveHostSpecificTlsBehaviorForHostname)
}
/* Shutdown Sequence */

View File

@@ -339,11 +339,15 @@
<div class="rpconfig_content" rpcfg="ssl">
<div class="ui segment">
<p>The table below shows which certificate will be served by Zoraxy when a client request the following hostnames.</p>
<table class="ui celled small compact table Tls_resolve_list">
<div class="ui blue message sni_grey_out_info" style="margin-bottom: 1em; display:none;">
<i class="info circle icon"></i>
Certificate dropdowns are greyed out because SNI is enabled
</div>
<table class="ui celled small compact table sortable Tls_resolve_list">
<thead>
<tr>
<th>Hostname</th>
<th>Resolve to Certificate</th>
<th class="no-sort">Resolve to Certificate</th>
</tr>
</thead>
<tbody>
@@ -359,18 +363,20 @@
<div class="ui checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="Tls_EnableLegacyCertificateMatching">
<label>Enable Legacy Certificate Matching<br>
<small>Use legacy filename / hostname matching for loading certificates</small>
<small>Use filename for hostname matching, faster but less accurate</small>
</label>
</div>
<div class="ui checkbox" style="margin-top: 0.4em;">
<div class="ui disabled checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="Tls_EnableAutoHTTPS">
<label>Enable Auto HTTPS<br>
<label>Enable Auto HTTPS (WIP)<br>
<small>Automatically request a certificate for the domain</small>
</label>
</div>
<br>
<div class="ui divider"></div>
<button class="ui basic small button getCertificateBtn" style="margin-left: 0.4em; margin-top: 0.4em;"><i class="green lock icon"></i> Get Certificate</button>
<button class="ui basic small button getSelfSignCertBtn" style="margin-left: 0.4em; margin-top: 0.4em;"><i class="yellow lock icon"></i> Generate Self-Signed Certificate</button>
</div>
</div>
<!-- Custom Headers -->
@@ -580,6 +586,28 @@
aliasDomains += `</small><br>`;
}
//Build the sorting value
let destSortValue = subd.ActiveOrigins.map(o => {
// Check if it's an IP address (with optional port)
let upstreamAddr = o.OriginIpOrDomain;
let subpath = "";
if (upstreamAddr.indexOf("/") !== -1) {
let parts = upstreamAddr.split("/");
subpath = parts.slice(1).join("/");
upstreamAddr = parts[0];
}
let ipPortRegex = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/;
if (ipPortRegex.test(upstreamAddr)) {
let [ip, port] = upstreamAddr.split(":");
// Convert IP to hex
let hexIp = ip.split('.').map(x => ('00' + parseInt(x).toString(16)).slice(-2)).join('');
let hexPort = port ? (port.length < 5 ? port.padStart(5, '0') : port) : '';
return hexIp + (hexPort ? ':' + hexPort : '') + "/" + subpath;
}
// Otherwise, treat it as a domain name
return upstreamAddr;
}).join(",");
//Build tag list
let tagList = renderTagList(subd);
let tagListEmpty = (subd.Tags.length == 0);
@@ -596,7 +624,7 @@
${aliasDomains}
<small class="accessRuleNameUnderHost" ruleid="${subd.AccessFilterUUID}"></small>
</td>
<td data-label="" editable="true" datatype="domain">
<td data-label="" editable="true" datatype="domain" data-sort-value="${destSortValue}" style="word-break: break-all;">
<div class="upstreamList">
${upstreams}
</div>
@@ -747,7 +775,7 @@
let newTlsOption = {
"DisableSNI": !enableSNI,
"DisableLegacyCertificateMatching": !enableLegacyCertificateMatching,
"EnableAutoHTTPS": enableAutoHTTPS
"EnableAutoHTTPS": enableAutoHTTPS,
}
$.cjax({
url: "/api/proxy/setTlsConfig",
@@ -769,6 +797,9 @@
function updateTlsResolveList(uuid){
let editor = $("#httprpEditModalWrapper");
editor.find(".certificateDropdown .ui.dropdown").off("change");
editor.find(".certificateDropdown .ui.dropdown").remove();
//Update the TLS resolve list
$.ajax({
url: "/api/cert/resolve?domain=" + uuid,
@@ -785,17 +816,60 @@
resolveList.append(`
<tr>
<td>${primaryDomain}</td>
<td>${certMap[primaryDomain] || "Fallback Certificate"}</td>
<td class="certificateDropdown" domain="${primaryDomain}">${certMap[primaryDomain] || "Fallback Certificate"}</td>
</tr>
`);
aliasDomains.forEach(alias => {
resolveList.append(`
<tr>
<td>${alias}</td>
<td>${certMap[alias] || "Fallback Certificate"}</td>
<td class="certificateDropdown" domain="${alias}">${certMap[alias] || "Fallback Certificate"}</td>
</tr>
`);
});
//Generate the certificate dropdown
generateCertificateDropdown(function(dropdown) {
let SNIEnabled = editor.find(".Tls_EnableSNI")[0].checked;
editor.find(".certificateDropdown").html(dropdown);
editor.find(".certificateDropdown").each(function() {
let dropdownDomain = $(this).attr("domain");
let selectedCertname = certMap[dropdownDomain];
if (selectedCertname) {
$(this).find(".ui.dropdown").dropdown("set selected", selectedCertname);
}
});
editor.find(".certificateDropdown .ui.dropdown").dropdown({
onChange: function(value, text, $selectedItem) {
console.log("Selected certificate for domain:", $(this).parent().attr("domain"), "Value:", value);
let domain = $(this).parent().attr("domain");
let newCertificateName = value;
$.cjax({
url: "/api/cert/setPreferredCertificate",
method: "POST",
data: {
"domain": domain,
"certname": newCertificateName
},
success: function(data) {
if (data.error !== undefined) {
msgbox(data.error, false, 3000);
} else {
msgbox("Preferred Certificate updated");
}
}
});
}
});
if (SNIEnabled) {
editor.find(".certificateDropdown .ui.dropdown").addClass("disabled");
editor.find(".sni_grey_out_info").show();
}else{
editor.find(".sni_grey_out_info").hide();
}
});
}
});
}
@@ -946,6 +1020,29 @@
renewCertificate(renewDomainKey, false, btn);
}
function generateSelfSignedCertificate(uuid, domains, btn=undefined){
let payload = JSON.stringify(domains);
$.cjax({
url: "/api/cert/selfsign",
data: {
"cn": uuid,
"domains": payload
},
success: function(data){
if (data.error == undefined){
msgbox("Self-Signed Certificate Generated", true);
resyncProxyEditorConfig();
if (typeof(initManagedDomainCertificateList) != undefined){
//Re-init the managed domain certificate list
initManagedDomainCertificateList();
}
}else{
msgbox(data.error, false);
}
}
});
}
/* Tags & Search */
function handleSearchInput(event){
if (event.key == "Escape"){
@@ -1074,6 +1171,28 @@
return subd;
}
// Generate a certificate dropdown for the HTTP Proxy Rule Editor
// so user can pick which certificate they want to use for the current editing hostname
function generateCertificateDropdown(callback){
$.ajax({
url: "/api/cert/list",
method: "GET",
success: function(data) {
let dropdown = $('<div class="ui fluid selection dropdown"></div>');
let menu = $('<div class="menu"></div>');
data.forEach(cert => {
menu.append(`<div class="item" data-value="${cert}">${cert}</div>`);
});
// Add a hidden input to store the selected certificate
dropdown.append('<input type="hidden" name="certificate">');
dropdown.append('<i class="dropdown icon"></i>');
dropdown.append('<div class="default text">Fallback Certificate</div>');
dropdown.append(menu);
callback(dropdown);
}
})
}
//Initialize the http proxy rule editor
function initHttpProxyRuleEditorModal(rulepayload){
let subd = JSON.parse(JSON.stringify(rulepayload));
@@ -1175,39 +1294,6 @@
});
editor.find(".downstream_alias_hostname").html(aliasHTML);
//TODO: Move this to SSL TLS section
let enableQuickRequestButton = true;
let domains = [subd.RootOrMatchingDomain]; //Domain for getting certificate if needed
for (var i = 0; i < subd.MatchingDomainAlias.length; i++){
let thisAliasName = subd.MatchingDomainAlias[i];
domains.push(thisAliasName);
}
//Check if the domain or alias contains wildcard, if yes, disabled the get certificate button
if (subd.RootOrMatchingDomain.indexOf("*") > -1){
enableQuickRequestButton = false;
}
if (subd.MatchingDomainAlias != undefined){
for (var i = 0; i < subd.MatchingDomainAlias.length; i++){
if (subd.MatchingDomainAlias[i].indexOf("*") > -1){
enableQuickRequestButton = false;
break;
}
}
}
let certificateDomains = encodeURIComponent(JSON.stringify(domains));
if (enableQuickRequestButton){
editor.find(".getCertificateBtn").removeClass("disabled");
}else{
editor.find(".getCertificateBtn").addClass("disabled");
}
editor.find(".getCertificateBtn").off("click").on("click", function(){
requestCertificateForExistingHost(uuid, certificateDomains, this);
});
/* ------------ Upstreams ------------ */
editor.find(".upstream_list").html(renderUpstreamList(subd));
@@ -1237,6 +1323,8 @@
editor.find(".vdir_list").html(renderVirtualDirectoryList(subd));
editor.find(".editVdirBtn").off("click").on("click", function(){
quickEditVdir(uuid);
//Temporary restore scroll
$("body").css("overflow", "auto");
});
/* ------------ Alias ------------ */
@@ -1336,6 +1424,7 @@
/* ------------ TLS ------------ */
updateTlsResolveList(uuid);
editor.find(".Tls_EnableSNI").prop("checked", !subd.TlsOptions.DisableSNI);
editor.find(".Tls_EnableLegacyCertificateMatching").prop("checked", !subd.TlsOptions.DisableLegacyCertificateMatching);
editor.find(".Tls_EnableAutoHTTPS").prop("checked", !!subd.TlsOptions.EnableAutoHTTPS);
@@ -1349,6 +1438,45 @@
saveTlsConfigs(uuid);
});
/* Quick access to get certificate for the current host */
let enableQuickRequestButton = true;
let domains = [subd.RootOrMatchingDomain]; //Domain for getting certificate if needed
for (var i = 0; i < subd.MatchingDomainAlias.length; i++){
let thisAliasName = subd.MatchingDomainAlias[i];
domains.push(thisAliasName);
}
//Check if the domain or alias contains wildcard, if yes, disabled the get certificate button
if (subd.RootOrMatchingDomain.indexOf("*") > -1){
enableQuickRequestButton = false;
}
if (subd.MatchingDomainAlias != undefined){
for (var i = 0; i < subd.MatchingDomainAlias.length; i++){
if (subd.MatchingDomainAlias[i].indexOf("*") > -1){
enableQuickRequestButton = false;
break;
}
}
}
if (enableQuickRequestButton){
editor.find(".getCertificateBtn").removeClass("disabled");
}else{
editor.find(".getCertificateBtn").addClass("disabled");
}
editor.find(".getCertificateBtn").off("click").on("click", function(){
let certificateDomains = encodeURIComponent(JSON.stringify(domains));
requestCertificateForExistingHost(uuid, certificateDomains, this);
});
// Bind event to self-signed certificate button
editor.find(".getSelfSignCertBtn").off("click").on("click", function() {
generateSelfSignedCertificate(uuid, domains, this);
});
/* ------------ Tags ------------ */
(()=>{
let payload = encodeURIComponent(JSON.stringify({
@@ -1411,7 +1539,6 @@
});
}
/*
Page Initialization Functions
*/
@@ -1436,7 +1563,9 @@
// there is a chance where the user has modified the Vdir
// we need to get the latest setting from server side and
// render it again
updateVdirInProxyEditor();
resyncProxyEditorConfig();
window.scrollTo(0, 0);
$("body").css("overflow", "hidden");
} else {
listProxyEndpoints();
//Reset the tag filter

View File

@@ -151,11 +151,31 @@
dataType: 'json',
success: function(data) {
$('#forwardAuthAddress').val(data.address);
$('#forwardAuthResponseHeaders').val(data.responseHeaders.join(","));
$('#forwardAuthResponseClientHeaders').val(data.responseClientHeaders.join(","));
$('#forwardAuthRequestHeaders').val(data.requestHeaders.join(","));
$('#forwardAuthRequestIncludedCookies').val(data.requestIncludedCookies.join(","));
$('#forwardAuthRequestExcludedCookies').val(data.requestExcludedCookies.join(","));
if (data.responseHeaders != null) {
$('#forwardAuthResponseHeaders').val(data.responseHeaders.join(","));
} else {
$('#forwardAuthResponseHeaders').val("");
}
if (data.responseClientHeaders != null) {
$('#forwardAuthResponseClientHeaders').val(data.responseClientHeaders.join(","));
} else {
$('#forwardAuthResponseClientHeaders').val("");
}
if (data.requestHeaders != null) {
$('#forwardAuthRequestHeaders').val(data.requestHeaders.join(","));
} else {
$('#forwardAuthRequestHeaders').val("");
}
if (data.requestIncludedCookies != null) {
$('#forwardAuthRequestIncludedCookies').val(data.requestIncludedCookies.join(","));
} else {
$('#forwardAuthRequestIncludedCookies').val("");
}
if (data.requestExcludedCookies != null) {
$('#forwardAuthRequestExcludedCookies').val(data.requestExcludedCookies.join(","));
} else {
$('#forwardAuthRequestExcludedCookies').val("");
}
},
error: function(jqXHR, textStatus, errorThrown) {
console.error('Error fetching SSO settings:', textStatus, errorThrown);

View File

@@ -137,7 +137,7 @@
});
function clearStreamProxyAddEditForm(){
$('#streamProxyForm input, #streamProxyForm select').val('');
$('#streamProxyForm').find('input:not([type=checkbox]), select').val('');
$('#streamProxyForm select').dropdown('clear');
$("#streamProxyForm input[name=timeout]").val(10);
$("#streamProxyForm .toggle.checkbox").checkbox("set unchecked");

View File

@@ -139,6 +139,26 @@
</tbody>
</table>
<div class="ui divider"></div>
<h4>Plugin IntroSpect Permitted API Endpoints</h4>
<p>The following API endpoints are registered by this plugin and will be accessible by the plugin's API key:</p>
<table class="ui basic celled unstackable table">
<thead>
<tr>
<th>Endpoint</th>
<th>Method</th>
<th>Reason</th>
</tr>
</thead>
<!-- This tbody will be filled by JavaScript -->
<tbody id="plugin_permitted_api_endpoints">
</tbody>
</table>
<p>
Note that the API endpoints are only accessible by the plugin's API key.
If the plugin does not have an API key, it will not be able to access these endpoints.
API keys are generated automatically by Zoraxy when a plugin with permitted API endpoints is enabled.
</p>
<div class="ui divider"></div>
</div>
</div>
</div>
@@ -219,6 +239,22 @@
$("#dynamic_capture_sniffing_path").text(dynamicCaptureSniffingPath);
$("#dynamic_capture_ingress").text(dynamicCaptureIngress);
$("#registered_ui_proxy_path").text(registeredUIProxyPath);
//Update permitted API endpoints
let apiEndpoints = data.Spec.permitted_api_endpoints;
if (apiEndpoints == null || apiEndpoints.length == 0) {
$("#plugin_permitted_api_endpoints").html('<tr><td colspan="3">No API endpoints registered</td></tr>');
} else {
let endpointRows = '';
apiEndpoints.forEach(function(endpoint) {
endpointRows += `<tr>
<td>${endpoint.endpoint}</td>
<td>${endpoint.method}</td>
<td>${endpoint.reason}</td>
</tr>`;
});
$("#plugin_permitted_api_endpoints").html(endpointRows);
}
});
}