49 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
Toby Chui
45506c8772 Added cert resolve viewer
- Added certificate resolve viewer on HTTP proxy rule editor
- Exposed SNI options (wip)
- Code optimize
2025-07-07 14:18:10 +08:00
Toby Chui
c091b9d1ca Added content security policy structure
- Added content security policy header generators structure (current not in used)
2025-07-07 13:25:07 +08:00
Toby Chui
691cb603ce Merge pull request #724 from 7brend7/sort-list-of-certificates
sort list of loaded certificates by ExpireDate
2025-07-07 10:51:35 +08:00
Borys Anikiyenko
e53724d6e5 sort list of loaded certificates by ExpireDate 2025-07-06 22:40:10 +03:00
Borys Anikiyenko
e225407b03 fix empty sso advanced parameters 2025-07-06 22:25:17 +03:00
Toby Chui
273cae2a98 Merge pull request #719 from jemmy1794/main
Add EnableLogging to Stream Proxy for log control
2025-07-03 20:25:39 +08:00
jemmy1794
6b3b89f7bf Add EnableLogging to Stream Proxy for log control
- Add `EnableLogging` to control TCP/UDP Connection logs to reduce log latency.
- Add `Enable Logging` Option in Stream Proxy rule.
- Update Stream Proxy UI.
2025-07-03 09:01:46 +08:00
Toby Chui
2d611a559a Optimized structure for stream proxy
- Separated instance and config for stream proxy
2025-07-02 21:03:57 +08:00
Toby Chui
6c5eba01c2 Update README.md
Added more contributors in community maintained section name list
2025-07-02 20:42:14 +08:00
Toby Chui
f641797d10 Merge pull request #718 from jemmy1794/Stream-Proxy
Add Proxy Protocol V1 option in TCP Stream Proxy and update Stream Proxy UI
2025-07-02 20:40:08 +08:00
Jemmy
f92ff068f3 Added Proxy Protocol V1 to Stream Proxy UI
- Added a checkbox for Proxy Protocol V1.
- Modified related Config setting function.
2025-07-02 18:04:26 +08:00
Jemmy
b59ac47c8c Added Proxy Protocol V1 function.
- Added useProxyProtocol in ProxyRelayConfig
- Added writeProxyProtocolHeaderV1 function
2025-07-02 17:58:26 +08:00
Toby Chui
8030f3d62a Fixed #688
- Added auto restart after config change in static web server
2025-06-30 20:34:42 +08:00
Toby Chui
f8f623e3e4 Update .gitignore
Ignored dist folder
2025-06-28 17:00:31 +08:00
Toby Chui
061839756c Merge pull request #711 from Morethanevil/main
Update CHANGELOG.md
2025-06-28 14:34:58 +08:00
Marcel
1dcaa0c257 Update CHANGELOG.md 2025-06-28 08:31:20 +02:00
Toby Chui
ffd3909964 Merge pull request #710 from tobychui/v3.2.4
V3.2.4 update
2025-06-28 10:06:23 +08:00
Toby Chui
3ddccdffce Merge branch 'v3.2.4' of https://github.com/tobychui/zoraxy into v3.2.4 2025-06-27 22:02:29 +08:00
Toby Chui
929d4cc82a Optimized SSO UI
- Added tab menu to SSO settings
2025-06-27 22:02:28 +08:00
Toby Chui
74a816216e Merge pull request #702 from PassiveLemon/main
Release type Docker workflows
2025-06-19 07:11:14 +08:00
PassiveLemon
4a093cf096 Merge branch 'tobychui:main' into main 2025-06-18 16:54:24 -04:00
PassiveLemon
68f9fccf3a refactor: release type workflows 2025-06-18 16:53:51 -04:00
58 changed files with 3442 additions and 2145 deletions

View File

@@ -2,7 +2,7 @@ name: Build and push Docker image
on:
release:
types: [ published ]
types: [ released, prereleased ]
jobs:
setup-build-push:
@@ -33,7 +33,8 @@ jobs:
run: |
cp -lr $GITHUB_WORKSPACE/src/ $GITHUB_WORKSPACE/docker/src/
- name: Build and push Docker image
- name: Build and push Docker image (Release)
if: "!github.event.release.prerelease"
uses: docker/build-push-action@v6
with:
context: ./docker
@@ -45,3 +46,15 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push Docker image (Prerelease)
if: "github.event.release.prerelease"
uses: docker/build-push-action@v6
with:
context: ./docker
push: true
platforms: linux/amd64,linux/arm64
tags: |
zoraxydocker/zoraxy:${{ github.event.release.tag_name }}
cache-from: type=gha
cache-to: type=gha,mode=max

13
.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
@@ -56,4 +58,7 @@ log
tmp
sys.*
www/html/index.html
*.exe
*.exe
/src/dist
/src/plugins

View File

@@ -1,3 +1,36 @@
# v3.2.4 28 Jun 2025
A big release since v3.1.9. Versions from 3.2.0 to 3.2.3 were prereleases.
+ Added Authentik support by [JokerQyou](https://github.com/tobychui/zoraxy/commits?author=JokerQyou)
+ Added pluginsystem and moved GAN and Zerotier to plugins
+ Add loopback detection [#573](https://github.com/tobychui/zoraxy/issues/573)
+ Fixed Dark theme not working with Advanced Option accordion [#591](https://github.com/tobychui/zoraxy/issues/591)
+ Update logger to include UserAgent by [Raithmir](https://github.com/Raithmir)
+ Fixed memory usage in UI [#600](https://github.com/tobychui/zoraxy/issues/600)
+ Added docker-compose.yml by [SamuelPalubaCZ](https://github.com/tobychui/zoraxy/commits?author=SamuelPalubaCZ)
+ Added more statistics for proxy hosts [#201](https://github.com/tobychui/zoraxy/issues/201) and [#608](https://github.com/tobychui/zoraxy/issues/608)
+ Fixed origin field in logs [#618](https://github.com/tobychui/zoraxy/issues/618)
+ Added FreeBSD support by Andreas Burri
+ Fixed HTTP proxy redirect [#626](https://github.com/tobychui/zoraxy/issues/626)
+ Fixed proxy handling #629](https://github.com/tobychui/zoraxy/issues/629)
+ Move Scope ID handling into CIDR check by [Nirostar](https://github.com/tobychui/zoraxy/commits?author=Nirostar)
+ Prevent the browser from filling the saved Zoraxy login account by [WHFo](https://github.com/tobychui/zoraxy/commits?author=WHFo)
+ Added port number and http proto to http proxy list link
+ Fixed headers for authelia by [james-d-elliott](https://github.com/tobychui/zoraxy/commits?author=james-d-elliott)
+ Refactored docker container list and UI improvements by [eyerrock](https://github.com/tobychui/zoraxy/commits?author=eyerrock)
+ Refactored Dockerfile by [PassiveLemon](https://github.com/tobychui/zoraxy/commits?author=PassiveLemon)
+ Added new HTTP proxy UI
+ Added inbound host name edit function
+ Added static web server option to disable listen to all interface
+ Merged SSO implementations (Oauth2) [#649](https://github.com/tobychui/zoraxy/pull/649)
+ Merged forward-auth optimization [#692(https://github.com/tobychui/zoraxy/pull/692)
+ Optimized SSO UI
+ Refactored docker image workflows by [PassiveLemon](https://github.com/tobychui/zoraxy/commits?author=PassiveLemon)
+ Added disable chunked transfer encoding checkbox (for upstreams that uses legacy HTTP implementations)
+ Bug fixes [#694](https://github.com/tobychui/zoraxy/issues/694), [#659](https://github.com/tobychui/zoraxy/issues/659) by [jemmy1794](https://github.com/tobychui/zoraxy/commits?author=jemmy1794), [#695](https://github.com/tobychui/zoraxy/issues/695)
# v3.1.9 1 Mar 2025
+ Fixed netstat underflow bug

View File

@@ -200,6 +200,10 @@ Some section of Zoraxy are contributed by our amazing community and if you have
- Docker Container List by [@eyerrock](https://github.com/eyerrock)
- Stream Proxy [@jemmy1794](https://github.com/jemmy1794)
- Change Log [@Morethanevil](https://github.com/Morethanevil)
### Looking for Maintainer
- ACME DNS Challenge Module

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

@@ -34,6 +34,7 @@ func RegisterHTTPProxyAPIs(authRouter *auth.RouterDef) {
authRouter.HandleFunc("/api/proxy/detail", ReverseProxyListDetail)
authRouter.HandleFunc("/api/proxy/edit", ReverseProxyHandleEditEndpoint)
authRouter.HandleFunc("/api/proxy/setAlias", ReverseProxyHandleAlias)
authRouter.HandleFunc("/api/proxy/setTlsConfig", ReverseProxyHandleSetTlsConfig)
authRouter.HandleFunc("/api/proxy/setHostname", ReverseProxyHandleSetHostname)
authRouter.HandleFunc("/api/proxy/del", DeleteProxyEndpoint)
authRouter.HandleFunc("/api/proxy/updateCredentials", UpdateProxyBasicAuthCredentials)
@@ -71,14 +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,180 +1,14 @@
package main
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"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)
}
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
@@ -185,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")
@@ -205,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)
}
}
@@ -223,144 +58,136 @@ 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
}
func handleCertTryResolve(w http.ResponseWriter, r *http.Request) {
// 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)
utils.SendErrorResponse(w, "invalid domain given")
return
}
// parse multipart form data
err = r.ParseMultipartForm(10 << 20) // 10 MB
// get the proxy rule, the pass in domain value must be root or matching domain
proxyRule, err := dynamicProxyRouter.GetProxyEndpointById(domain, false)
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
//Try to resolve the domain via alias
proxyRule, err = dynamicProxyRouter.GetProxyEndpointByAlias(domain)
if err != nil {
//No matching rule found
utils.SendErrorResponse(w, "proxy rule not found for domain: "+domain)
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
// list all the alias domains for this rule
allDomains := []string{proxyRule.RootOrMatchingDomain}
aliasDomains := []string{}
for _, alias := range proxyRule.MatchingDomainAlias {
if alias != "" {
aliasDomains = append(aliasDomains, alias)
allDomains = append(allDomains, alias)
}
}
//Update cert list
tlsCertManager.UpdateLoadedCertList()
// Try to resolve the domain
domainKeyPairs := map[string]string{}
for _, thisDomain := range allDomains {
pubkey, prikey, err := tlsCertManager.GetCertificateByHostname(thisDomain)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
// send response
fmt.Fprintln(w, "File upload successful!")
//Make sure pubkey and private key are not empty
if pubkey == "" || prikey == "" {
domainKeyPairs[thisDomain] = ""
} else {
//Store the key pair
keyname := strings.TrimSuffix(filepath.Base(pubkey), filepath.Ext(pubkey))
if keyname == "localhost" {
//Internal certs like localhost should not be used
//report as "fallback" key
keyname = "fallback certificate"
}
domainKeyPairs[thisDomain] = keyname
}
}
//A domain must be UseDNSValidation if it is a wildcard domain or its alias is a wildcard domain
useDNSValidation := strings.HasPrefix(proxyRule.RootOrMatchingDomain, "*")
for _, alias := range aliasDomains {
if strings.HasPrefix(alias, "*") || strings.HasPrefix(domain, "*") {
useDNSValidation = true
}
}
type CertInfo struct {
Domain string `json:"domain"`
AliasDomains []string `json:"alias_domains"`
DomainKeyPair map[string]string `json:"domain_key_pair"`
UseDNSValidation bool `json:"use_dns_validation"`
}
result := &CertInfo{
Domain: proxyRule.RootOrMatchingDomain,
AliasDomains: aliasDomains,
DomainKeyPair: domainKeyPairs,
UseDNSValidation: useDNSValidation,
}
js, _ := json.Marshal(result)
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

@@ -15,6 +15,7 @@ import (
"imuslab.com/zoraxy/mod/dynamicproxy"
"imuslab.com/zoraxy/mod/dynamicproxy/loadbalance"
"imuslab.com/zoraxy/mod/tlscert"
"imuslab.com/zoraxy/mod/utils"
)
@@ -59,12 +60,18 @@ func LoadReverseProxyConfig(configFilepath string) error {
thisConfigEndpoint.Tags = []string{}
}
//Make sure the TLS options are not nil
if thisConfigEndpoint.TlsOptions == nil {
thisConfigEndpoint.TlsOptions = tlscert.GetDefaultHostSpecificTlsBehavior()
}
//Matching domain not set. Assume root
if thisConfigEndpoint.RootOrMatchingDomain == "" {
thisConfigEndpoint.RootOrMatchingDomain = "/"
}
if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeRoot {
switch thisConfigEndpoint.ProxyType {
case dynamicproxy.ProxyTypeRoot:
//This is a root config file
rootProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
if err != nil {
@@ -73,7 +80,7 @@ func LoadReverseProxyConfig(configFilepath string) error {
dynamicProxyRouter.SetProxyRouteAsRoot(rootProxyEndpoint)
} else if thisConfigEndpoint.ProxyType == dynamicproxy.ProxyTypeHost {
case dynamicproxy.ProxyTypeHost:
//This is a host config file
readyProxyEndpoint, err := dynamicProxyRouter.PrepareProxyRoute(&thisConfigEndpoint)
if err != nil {
@@ -81,7 +88,7 @@ func LoadReverseProxyConfig(configFilepath string) error {
}
dynamicProxyRouter.AddProxyRouteToRuntime(readyProxyEndpoint)
} else {
default:
return errors.New("not supported proxy type")
}
@@ -101,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)
@@ -118,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)
@@ -172,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")
@@ -277,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

@@ -0,0 +1,123 @@
package permissionpolicy
import (
"net/http"
"strings"
)
/*
Content Security Policy
This is a content security policy header modifier that changes
the request content security policy fields
author: tobychui
//TODO: intergrate this with the dynamic proxy module
*/
type ContentSecurityPolicy struct {
DefaultSrc []string `json:"default_src"`
ScriptSrc []string `json:"script_src"`
StyleSrc []string `json:"style_src"`
ImgSrc []string `json:"img_src"`
ConnectSrc []string `json:"connect_src"`
FontSrc []string `json:"font_src"`
ObjectSrc []string `json:"object_src"`
MediaSrc []string `json:"media_src"`
FrameSrc []string `json:"frame_src"`
WorkerSrc []string `json:"worker_src"`
ChildSrc []string `json:"child_src"`
ManifestSrc []string `json:"manifest_src"`
PrefetchSrc []string `json:"prefetch_src"`
FormAction []string `json:"form_action"`
FrameAncestors []string `json:"frame_ancestors"`
BaseURI []string `json:"base_uri"`
Sandbox []string `json:"sandbox"`
ReportURI []string `json:"report_uri"`
ReportTo []string `json:"report_to"`
UpgradeInsecureRequests bool `json:"upgrade_insecure_requests"`
BlockAllMixedContent bool `json:"block_all_mixed_content"`
}
// GetDefaultContentSecurityPolicy returns a ContentSecurityPolicy struct with default permissive settings
func GetDefaultContentSecurityPolicy() *ContentSecurityPolicy {
return &ContentSecurityPolicy{
DefaultSrc: []string{"*"},
ScriptSrc: []string{"*"},
StyleSrc: []string{"*"},
ImgSrc: []string{"*"},
ConnectSrc: []string{"*"},
FontSrc: []string{"*"},
ObjectSrc: []string{"*"},
MediaSrc: []string{"*"},
FrameSrc: []string{"*"},
WorkerSrc: []string{"*"},
ChildSrc: []string{"*"},
ManifestSrc: []string{"*"},
PrefetchSrc: []string{"*"},
FormAction: []string{"*"},
FrameAncestors: []string{"*"},
BaseURI: []string{"*"},
Sandbox: []string{},
ReportURI: []string{},
ReportTo: []string{},
UpgradeInsecureRequests: false,
BlockAllMixedContent: false,
}
}
// ToHeader converts a ContentSecurityPolicy struct into a CSP header key-value pair
func (csp *ContentSecurityPolicy) ToHeader() []string {
directives := []string{}
addDirective := func(name string, sources []string) {
if len(sources) > 0 {
directives = append(directives, name+" "+strings.Join(sources, " "))
}
}
addDirective("default-src", csp.DefaultSrc)
addDirective("script-src", csp.ScriptSrc)
addDirective("style-src", csp.StyleSrc)
addDirective("img-src", csp.ImgSrc)
addDirective("connect-src", csp.ConnectSrc)
addDirective("font-src", csp.FontSrc)
addDirective("object-src", csp.ObjectSrc)
addDirective("media-src", csp.MediaSrc)
addDirective("frame-src", csp.FrameSrc)
addDirective("worker-src", csp.WorkerSrc)
addDirective("child-src", csp.ChildSrc)
addDirective("manifest-src", csp.ManifestSrc)
addDirective("prefetch-src", csp.PrefetchSrc)
addDirective("form-action", csp.FormAction)
addDirective("frame-ancestors", csp.FrameAncestors)
addDirective("base-uri", csp.BaseURI)
if len(csp.Sandbox) > 0 {
directives = append(directives, "sandbox "+strings.Join(csp.Sandbox, " "))
}
if len(csp.ReportURI) > 0 {
addDirective("report-uri", csp.ReportURI)
}
if len(csp.ReportTo) > 0 {
addDirective("report-to", csp.ReportTo)
}
if csp.UpgradeInsecureRequests {
directives = append(directives, "upgrade-insecure-requests")
}
if csp.BlockAllMixedContent {
directives = append(directives, "block-all-mixed-content")
}
headerValue := strings.Join(directives, "; ")
return []string{"Content-Security-Policy", headerValue}
}
// InjectContentSecurityPolicyHeader injects the CSP header into the response
func InjectContentSecurityPolicyHeader(w http.ResponseWriter, csp *ContentSecurityPolicy) {
if csp == nil || w.Header().Get("Content-Security-Policy") != "" {
return
}
headerKV := csp.ToHeader()
w.Header().Set(headerKV[0], headerKV[1])
}

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

@@ -8,6 +8,7 @@ import (
"time"
"imuslab.com/zoraxy/mod/dynamicproxy/dpcore"
"imuslab.com/zoraxy/mod/utils"
)
/*
@@ -105,3 +106,49 @@ func (router *Router) RemoveProxyEndpointByRootname(rootnameOrMatchingDomain str
return targetEpt.Remove()
}
// GetProxyEndpointById retrieves a proxy endpoint by its ID from the Router's ProxyEndpoints map.
// It returns the ProxyEndpoint if found, or an error if not found.
func (h *Router) GetProxyEndpointById(searchingDomain string, includeAlias bool) (*ProxyEndpoint, error) {
var found *ProxyEndpoint
h.ProxyEndpoints.Range(func(key, value interface{}) bool {
proxy, ok := value.(*ProxyEndpoint)
if ok && (proxy.RootOrMatchingDomain == searchingDomain || (includeAlias && utils.StringInArray(proxy.MatchingDomainAlias, searchingDomain))) {
found = proxy
return false // stop iteration
}
return true // continue iteration
})
if found != nil {
return found, nil
}
return nil, errors.New("proxy rule with given id not found")
}
func (h *Router) GetProxyEndpointByAlias(alias string) (*ProxyEndpoint, error) {
var found *ProxyEndpoint
h.ProxyEndpoints.Range(func(key, value interface{}) bool {
proxy, ok := value.(*ProxyEndpoint)
if !ok {
return true
}
//Also check for wildcard aliases that matches the alias
for _, thisAlias := range proxy.MatchingDomainAlias {
if ok && thisAlias == alias {
found = proxy
return false // stop iteration
} else if ok && strings.HasPrefix(thisAlias, "*") {
//Check if the alias matches a wildcard alias
if strings.HasSuffix(alias, thisAlias[1:]) {
found = proxy
return false // stop iteration
}
}
}
return true // continue iteration
})
if found != nil {
return found, nil
}
return nil, errors.New("proxy rule with given alias not found")
}

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
}
@@ -175,7 +179,8 @@ type ProxyEndpoint struct {
Disabled bool //If the rule is disabled
//Inbound TLS/SSL Related
BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil)
BypassGlobalTLS bool //Bypass global TLS setting options if TLS Listener enabled (parent.tlsListener != nil)
TlsOptions *tlscert.HostSpecificTlsBehavior //TLS options for this endpoint, if nil, use global TLS options
//Virtual Directories
VirtualDirectories []*VirtualDirectoryEndpoint

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

@@ -47,15 +47,19 @@ func (m *Manager) HandleAddProxyConfig(w http.ResponseWriter, r *http.Request) {
useTCP, _ := utils.PostBool(r, "useTCP")
useUDP, _ := utils.PostBool(r, "useUDP")
useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol")
enableLogging, _ := utils.PostBool(r, "enableLogging")
//Create the target config
newConfigUUID := m.NewConfig(&ProxyRelayOptions{
Name: name,
ListeningAddr: strings.TrimSpace(listenAddr),
ProxyAddr: strings.TrimSpace(proxyAddr),
Timeout: timeout,
UseTCP: useTCP,
UseUDP: useUDP,
Name: name,
ListeningAddr: strings.TrimSpace(listenAddr),
ProxyAddr: strings.TrimSpace(proxyAddr),
Timeout: timeout,
UseTCP: useTCP,
UseUDP: useUDP,
UseProxyProtocol: useProxyProtocol,
EnableLogging: enableLogging,
})
js, _ := json.Marshal(newConfigUUID)
@@ -75,6 +79,8 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request)
proxyAddr, _ := utils.PostPara(r, "proxyAddr")
useTCP, _ := utils.PostBool(r, "useTCP")
useUDP, _ := utils.PostBool(r, "useUDP")
useProxyProtocol, _ := utils.PostBool(r, "useProxyProtocol")
enableLogging, _ := utils.PostBool(r, "enableLogging")
newTimeoutStr, _ := utils.PostPara(r, "timeout")
newTimeout := -1
@@ -86,8 +92,21 @@ func (m *Manager) HandleEditProxyConfigs(w http.ResponseWriter, r *http.Request)
}
}
// Create a new ProxyRuleUpdateConfig with the extracted parameters
newConfig := &ProxyRuleUpdateConfig{
InstanceUUID: configUUID,
NewName: newName,
NewListeningAddr: listenAddr,
NewProxyAddr: proxyAddr,
UseTCP: useTCP,
UseUDP: useUDP,
UseProxyProtocol: useProxyProtocol,
EnableLogging: enableLogging,
NewTimeout: newTimeout,
}
// Call the EditConfig method to modify the configuration
err = m.EditConfig(configUUID, newName, listenAddr, proxyAddr, useTCP, useUDP, newTimeout)
err = m.EditConfig(newConfig)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return

View File

@@ -0,0 +1,110 @@
package streamproxy
/*
Instances.go
This file contains the methods to start, stop, and manage the proxy relay instances.
*/
import (
"errors"
"log"
"time"
)
func (c *ProxyRelayInstance) LogMsg(message string, originalError error) {
if !c.EnableLogging {
return
}
if originalError != nil {
log.Println(message, "error:", originalError)
} else {
log.Println(message)
}
}
// Start a proxy if stopped
func (c *ProxyRelayInstance) Start() error {
if c.IsRunning() {
c.Running = true
return errors.New("proxy already running")
}
// Create a stopChan to control the loop
tcpStopChan := make(chan bool)
udpStopChan := make(chan bool)
//Start the proxy service
if c.UseUDP {
c.udpStopChan = udpStopChan
go func() {
err := c.ForwardUDP(c.ListeningAddress, c.ProxyTargetAddr, udpStopChan)
if err != nil {
if !c.UseTCP {
c.Running = false
c.udpStopChan = nil
c.parent.SaveConfigToDatabase()
}
c.parent.logf("[proto:udp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err)
}
}()
}
if c.UseTCP {
c.tcpStopChan = tcpStopChan
go func() {
//Default to transport mode
err := c.Port2host(c.ListeningAddress, c.ProxyTargetAddr, tcpStopChan)
if err != nil {
c.Running = false
c.tcpStopChan = nil
c.parent.SaveConfigToDatabase()
c.parent.logf("[proto:tcp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err)
}
}()
}
//Successfully spawned off the proxy routine
c.Running = true
c.parent.SaveConfigToDatabase()
return nil
}
// Return if a proxy config is running
func (c *ProxyRelayInstance) IsRunning() bool {
return c.tcpStopChan != nil || c.udpStopChan != nil
}
// Restart a proxy config
func (c *ProxyRelayInstance) Restart() {
if c.IsRunning() {
c.Stop()
}
time.Sleep(3000 * time.Millisecond)
c.Start()
}
// Stop a running proxy if running
func (c *ProxyRelayInstance) Stop() {
c.parent.logf("Stopping Stream Proxy "+c.Name, nil)
if c.udpStopChan != nil {
c.parent.logf("Stopping UDP for "+c.Name, nil)
c.udpStopChan <- true
c.udpStopChan = nil
}
if c.tcpStopChan != nil {
c.parent.logf("Stopping TCP for "+c.Name, nil)
c.tcpStopChan <- true
c.tcpStopChan = nil
}
c.parent.logf("Stopped Stream Proxy "+c.Name, nil)
c.Running = false
//Update the running status
c.parent.SaveConfigToDatabase()
}

View File

@@ -8,7 +8,6 @@ import (
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"imuslab.com/zoraxy/mod/info/logger"
@@ -24,24 +23,44 @@ import (
*/
type ProxyRelayOptions struct {
Name string
ListeningAddr string
ProxyAddr string
Timeout int
UseTCP bool
UseUDP bool
Name string
ListeningAddr string
ProxyAddr string
Timeout int
UseTCP bool
UseUDP bool
UseProxyProtocol bool
EnableLogging bool
}
type ProxyRelayConfig struct {
UUID string //A UUIDv4 representing this config
Name string //Name of the config
Running bool //Status, read only
AutoStart bool //If the service suppose to started automatically
ListeningAddress string //Listening Address, usually 127.0.0.1:port
ProxyTargetAddr string //Proxy target address
UseTCP bool //Enable TCP proxy
UseUDP bool //Enable UDP proxy
Timeout int //Timeout for connection in sec
// ProxyRuleUpdateConfig is used to update the proxy rule config
type ProxyRuleUpdateConfig struct {
InstanceUUID string //The target instance UUID to update
NewName string //New name for the instance, leave empty for no change
NewListeningAddr string //New listening address, leave empty for no change
NewProxyAddr string //New proxy target address, leave empty for no change
UseTCP bool //Enable TCP proxy, default to false
UseUDP bool //Enable UDP proxy, default to false
UseProxyProtocol bool //Enable Proxy Protocol, default to false
EnableLogging bool //Enable Logging TCP/UDP Message, default to true
NewTimeout int //New timeout for the connection, leave -1 for no change
}
type ProxyRelayInstance struct {
/* Runtime Config */
UUID string //A UUIDv4 representing this config
Name string //Name of the config
Running bool //Status, read only
AutoStart bool //If the service suppose to started automatically
ListeningAddress string //Listening Address, usually 127.0.0.1:port
ProxyTargetAddr string //Proxy target address
UseTCP bool //Enable TCP proxy
UseUDP bool //Enable UDP proxy
UseProxyProtocol bool //Enable Proxy Protocol
EnableLogging bool //Enable logging for ProxyInstance
Timeout int //Timeout for connection in sec
/* Internal */
tcpStopChan chan bool //Stop channel for TCP listener
udpStopChan chan bool //Stop channel for UDP listener
aTobAccumulatedByteTransfer atomic.Int64 //Accumulated byte transfer from A to B
@@ -60,13 +79,14 @@ type Options struct {
type Manager struct {
//Config and stores
Options *Options
Configs []*ProxyRelayConfig
Configs []*ProxyRelayInstance
//Realtime Statistics
Connections int //currently connected connect counts
}
// NewStreamProxy creates a new stream proxy manager with the given options
func NewStreamProxy(options *Options) (*Manager, error) {
if !utils.FileExists(options.ConfigStore) {
err := os.MkdirAll(options.ConfigStore, 0775)
@@ -76,7 +96,7 @@ func NewStreamProxy(options *Options) (*Manager, error) {
}
//Load relay configs from db
previousRules := []*ProxyRelayConfig{}
previousRules := []*ProxyRelayInstance{}
streamProxyConfigFiles, err := filepath.Glob(options.ConfigStore + "/*.config")
if err != nil {
return nil, err
@@ -89,7 +109,7 @@ func NewStreamProxy(options *Options) (*Manager, error) {
options.Logger.PrintAndLog("stream-prox", "Read stream proxy config failed", err)
continue
}
thisRelayConfig := &ProxyRelayConfig{}
thisRelayConfig := &ProxyRelayInstance{}
err = json.Unmarshal(configBytes, thisRelayConfig)
if err != nil {
options.Logger.PrintAndLog("stream-prox", "Unmarshal stream proxy config failed", err)
@@ -142,6 +162,7 @@ func (m *Manager) logf(message string, originalError error) {
m.Options.Logger.PrintAndLog("stream-prox", message, originalError)
}
// NewConfig creates a new proxy relay config with the given options
func (m *Manager) NewConfig(config *ProxyRelayOptions) string {
//Generate two zero value for atomic int64
aAcc := atomic.Int64{}
@@ -150,13 +171,15 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string {
bAcc.Store(0)
//Generate a new config from options
configUUID := uuid.New().String()
thisConfig := ProxyRelayConfig{
thisConfig := ProxyRelayInstance{
UUID: configUUID,
Name: config.Name,
ListeningAddress: config.ListeningAddr,
ProxyTargetAddr: config.ProxyAddr,
UseTCP: config.UseTCP,
UseUDP: config.UseUDP,
UseProxyProtocol: config.UseProxyProtocol,
EnableLogging: config.EnableLogging,
Timeout: config.Timeout,
tcpStopChan: nil,
udpStopChan: nil,
@@ -170,7 +193,7 @@ func (m *Manager) NewConfig(config *ProxyRelayOptions) string {
return configUUID
}
func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayConfig, error) {
func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayInstance, error) {
// Find and return the config with the specified UUID
for _, config := range m.Configs {
if config.UUID == configUUID {
@@ -181,32 +204,34 @@ func (m *Manager) GetConfigByUUID(configUUID string) (*ProxyRelayConfig, error)
}
// Edit the config based on config UUID, leave empty for unchange fields
func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr string, newProxyAddr string, useTCP bool, useUDP bool, newTimeout int) error {
func (m *Manager) EditConfig(newConfig *ProxyRuleUpdateConfig) error {
// Find the config with the specified UUID
foundConfig, err := m.GetConfigByUUID(configUUID)
foundConfig, err := m.GetConfigByUUID(newConfig.InstanceUUID)
if err != nil {
return err
}
// Validate and update the fields
if newName != "" {
foundConfig.Name = newName
if newConfig.NewName != "" {
foundConfig.Name = newConfig.NewName
}
if newListeningAddr != "" {
foundConfig.ListeningAddress = newListeningAddr
if newConfig.NewListeningAddr != "" {
foundConfig.ListeningAddress = newConfig.NewListeningAddr
}
if newProxyAddr != "" {
foundConfig.ProxyTargetAddr = newProxyAddr
if newConfig.NewProxyAddr != "" {
foundConfig.ProxyTargetAddr = newConfig.NewProxyAddr
}
foundConfig.UseTCP = useTCP
foundConfig.UseUDP = useUDP
foundConfig.UseTCP = newConfig.UseTCP
foundConfig.UseUDP = newConfig.UseUDP
foundConfig.UseProxyProtocol = newConfig.UseProxyProtocol
foundConfig.EnableLogging = newConfig.EnableLogging
if newTimeout != -1 {
if newTimeout < 0 {
if newConfig.NewTimeout != -1 {
if newConfig.NewTimeout < 0 {
return errors.New("invalid timeout value given")
}
foundConfig.Timeout = newTimeout
foundConfig.Timeout = newConfig.NewTimeout
}
m.SaveConfigToDatabase()
@@ -215,12 +240,11 @@ func (m *Manager) EditConfig(configUUID string, newName string, newListeningAddr
if foundConfig.IsRunning() {
foundConfig.Restart()
}
return nil
}
// Remove the config from file by UUID
func (m *Manager) RemoveConfig(configUUID string) error {
//Remove the config from file
err := os.Remove(filepath.Join(m.Options.ConfigStore, configUUID+".config"))
if err != nil {
return err
@@ -250,91 +274,3 @@ func (m *Manager) SaveConfigToDatabase() {
}
}
}
/*
Config Functions
*/
// Start a proxy if stopped
func (c *ProxyRelayConfig) Start() error {
if c.IsRunning() {
c.Running = true
return errors.New("proxy already running")
}
// Create a stopChan to control the loop
tcpStopChan := make(chan bool)
udpStopChan := make(chan bool)
//Start the proxy service
if c.UseUDP {
c.udpStopChan = udpStopChan
go func() {
err := c.ForwardUDP(c.ListeningAddress, c.ProxyTargetAddr, udpStopChan)
if err != nil {
if !c.UseTCP {
c.Running = false
c.udpStopChan = nil
c.parent.SaveConfigToDatabase()
}
c.parent.logf("[proto:udp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err)
}
}()
}
if c.UseTCP {
c.tcpStopChan = tcpStopChan
go func() {
//Default to transport mode
err := c.Port2host(c.ListeningAddress, c.ProxyTargetAddr, tcpStopChan)
if err != nil {
c.Running = false
c.tcpStopChan = nil
c.parent.SaveConfigToDatabase()
c.parent.logf("[proto:tcp] Error starting stream proxy "+c.Name+"("+c.UUID+")", err)
}
}()
}
//Successfully spawned off the proxy routine
c.Running = true
c.parent.SaveConfigToDatabase()
return nil
}
// Return if a proxy config is running
func (c *ProxyRelayConfig) IsRunning() bool {
return c.tcpStopChan != nil || c.udpStopChan != nil
}
// Restart a proxy config
func (c *ProxyRelayConfig) Restart() {
if c.IsRunning() {
c.Stop()
}
time.Sleep(3000 * time.Millisecond)
c.Start()
}
// Stop a running proxy if running
func (c *ProxyRelayConfig) Stop() {
c.parent.logf("Stopping Stream Proxy "+c.Name, nil)
if c.udpStopChan != nil {
c.parent.logf("Stopping UDP for "+c.Name, nil)
c.udpStopChan <- true
c.udpStopChan = nil
}
if c.tcpStopChan != nil {
c.parent.logf("Stopping TCP for "+c.Name, nil)
c.tcpStopChan <- true
c.tcpStopChan = nil
}
c.parent.logf("Stopped Stream Proxy "+c.Name, nil)
c.Running = false
//Update the running status
c.parent.SaveConfigToDatabase()
}

View File

@@ -12,7 +12,7 @@ func TestPort2Port(t *testing.T) {
stopChan := make(chan bool)
// Create a ProxyRelayConfig with dummy values
config := &streamproxy.ProxyRelayConfig{
config := &streamproxy.ProxyRelayInstance{
Timeout: 1,
}

View File

@@ -2,6 +2,7 @@ package streamproxy
import (
"errors"
"fmt"
"io"
"log"
"net"
@@ -30,48 +31,67 @@ func isValidPort(port string) bool {
return true
}
func connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup, accumulator *atomic.Int64) {
func (c *ProxyRelayInstance) connCopy(conn1 net.Conn, conn2 net.Conn, wg *sync.WaitGroup, accumulator *atomic.Int64) {
n, err := io.Copy(conn1, conn2)
if err != nil {
return
}
accumulator.Add(n) //Add to accumulator
conn1.Close()
log.Println("[←]", "close the connect at local:["+conn1.LocalAddr().String()+"] and remote:["+conn1.RemoteAddr().String()+"]")
c.LogMsg("[←] close the connect at local:["+conn1.LocalAddr().String()+"] and remote:["+conn1.RemoteAddr().String()+"]", nil)
//conn2.Close()
//log.Println("[←]", "close the connect at local:["+conn2.LocalAddr().String()+"] and remote:["+conn2.RemoteAddr().String()+"]")
//c.LogMsg("[←] close the connect at local:["+conn2.LocalAddr().String()+"] and remote:["+conn2.RemoteAddr().String()+"]", nil)
wg.Done()
}
func forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.Int64) {
log.Printf("[+] start transmit. [%s],[%s] <-> [%s],[%s] \n", conn1.LocalAddr().String(), conn1.RemoteAddr().String(), conn2.LocalAddr().String(), conn2.RemoteAddr().String())
func writeProxyProtocolHeaderV1(dst net.Conn, src net.Conn) error {
clientAddr, ok1 := src.RemoteAddr().(*net.TCPAddr)
proxyAddr, ok2 := src.LocalAddr().(*net.TCPAddr)
if !ok1 || !ok2 {
return errors.New("invalid TCP address for proxy protocol")
}
header := fmt.Sprintf("PROXY TCP4 %s %s %d %d\r\n",
clientAddr.IP.String(),
proxyAddr.IP.String(),
clientAddr.Port,
proxyAddr.Port)
_, err := dst.Write([]byte(header))
return err
}
func (c *ProxyRelayInstance) forward(conn1 net.Conn, conn2 net.Conn, aTob *atomic.Int64, bToa *atomic.Int64) {
msg := fmt.Sprintf("[+] start transmit. [%s],[%s] <-> [%s],[%s]",
conn1.LocalAddr().String(), conn1.RemoteAddr().String(),
conn2.LocalAddr().String(), conn2.RemoteAddr().String())
c.LogMsg(msg, nil)
var wg sync.WaitGroup
// wait tow goroutines
wg.Add(2)
go connCopy(conn1, conn2, &wg, aTob)
go connCopy(conn2, conn1, &wg, bToa)
//blocking when the wg is locked
go c.connCopy(conn1, conn2, &wg, aTob)
go c.connCopy(conn2, conn1, &wg, bToa)
wg.Wait()
}
func (c *ProxyRelayConfig) accept(listener net.Listener) (net.Conn, error) {
func (c *ProxyRelayInstance) accept(listener net.Listener) (net.Conn, error) {
conn, err := listener.Accept()
if err != nil {
return nil, err
}
//Check if connection in blacklist or whitelist
// Check if connection in blacklist or whitelist
if addr, ok := conn.RemoteAddr().(*net.TCPAddr); ok {
if !c.parent.Options.AccessControlHandler(conn) {
time.Sleep(300 * time.Millisecond)
conn.Close()
log.Println("[x]", "Connection from "+addr.IP.String()+" rejected by access control policy")
c.LogMsg("[x] Connection from "+addr.IP.String()+" rejected by access control policy", nil)
return nil, errors.New("Connection from " + addr.IP.String() + " rejected by access control policy")
}
}
log.Println("[√]", "accept a new client. remote address:["+conn.RemoteAddr().String()+"], local address:["+conn.LocalAddr().String()+"]")
return conn, err
c.LogMsg("[√] accept a new client. remote address:["+conn.RemoteAddr().String()+"], local address:["+conn.LocalAddr().String()+"]", nil)
return conn, nil
}
func startListener(address string) (net.Listener, error) {
@@ -92,7 +112,7 @@ func startListener(address string) (net.Listener, error) {
portA -> server
server -> portB
*/
func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, stopChan chan bool) error {
func (c *ProxyRelayInstance) Port2host(allowPort string, targetAddress string, stopChan chan bool) error {
listenerStartingAddr := allowPort
if isValidPort(allowPort) {
//number only, e.g. 8080
@@ -112,7 +132,7 @@ func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, sto
//Start stop handler
go func() {
<-stopChan
log.Println("[x]", "Received stop signal. Exiting Port to Host forwarder")
c.LogMsg("[x] Received stop signal. Exiting Port to Host forwarder", nil)
server.Close()
}()
@@ -129,18 +149,32 @@ func (c *ProxyRelayConfig) Port2host(allowPort string, targetAddress string, sto
}
go func(targetAddress string) {
log.Println("[+]", "start connect host:["+targetAddress+"]")
c.LogMsg("[+] start connect host:["+targetAddress+"]", nil)
target, err := net.Dial("tcp", targetAddress)
if err != nil {
// temporarily unavailable, don't use fatal.
log.Println("[x]", "connect target address ["+targetAddress+"] faild. retry in ", c.Timeout, "seconds. ")
c.LogMsg("[x] connect target address ["+targetAddress+"] failed. retry in "+strconv.Itoa(c.Timeout)+" seconds.", nil)
conn.Close()
log.Println("[←]", "close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]")
c.LogMsg("[←] close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]", nil)
time.Sleep(time.Duration(c.Timeout) * time.Second)
return
}
log.Println("[→]", "connect target address ["+targetAddress+"] success.")
forward(target, conn, &c.aTobAccumulatedByteTransfer, &c.bToaAccumulatedByteTransfer)
c.LogMsg("[→] connect target address ["+targetAddress+"] success.", nil)
if c.UseProxyProtocol {
c.LogMsg("[+] write proxy protocol header to target address ["+targetAddress+"]", nil)
err = writeProxyProtocolHeaderV1(target, conn)
if err != nil {
c.LogMsg("[x] Write proxy protocol header failed: "+err.Error(), nil)
target.Close()
conn.Close()
c.LogMsg("[←] close the connect at local:["+conn.LocalAddr().String()+"] and remote:["+conn.RemoteAddr().String()+"]", nil)
time.Sleep(time.Duration(c.Timeout) * time.Second)
return
}
}
c.forward(target, conn, &c.aTobAccumulatedByteTransfer, &c.bToaAccumulatedByteTransfer)
}(targetAddress)
}
}

View File

@@ -53,7 +53,7 @@ func initUDPConnections(listenAddr string, targetAddress string) (*net.UDPConn,
}
// Go routine which manages connection from server to single client
func (c *ProxyRelayConfig) RunUDPConnectionRelay(conn *udpClientServerConn, lisenter *net.UDPConn) {
func (c *ProxyRelayInstance) RunUDPConnectionRelay(conn *udpClientServerConn, lisenter *net.UDPConn) {
var buffer [1500]byte
for {
// Read from server
@@ -74,7 +74,7 @@ func (c *ProxyRelayConfig) RunUDPConnectionRelay(conn *udpClientServerConn, lise
}
// Close all connections that waiting for read from server
func (c *ProxyRelayConfig) CloseAllUDPConnections() {
func (c *ProxyRelayInstance) CloseAllUDPConnections() {
c.udpClientMap.Range(func(clientAddr, clientServerConn interface{}) bool {
conn := clientServerConn.(*udpClientServerConn)
conn.ServerConn.Close()
@@ -82,7 +82,7 @@ func (c *ProxyRelayConfig) CloseAllUDPConnections() {
})
}
func (c *ProxyRelayConfig) ForwardUDP(address1, address2 string, stopChan chan bool) error {
func (c *ProxyRelayInstance) ForwardUDP(address1, address2 string, stopChan chan bool) error {
//By default the incoming listen Address is int
//We need to add the loopback address into it
if isValidPort(address1) {
@@ -138,12 +138,12 @@ func (c *ProxyRelayConfig) ForwardUDP(address1, address2 string, stopChan chan b
continue
}
c.udpClientMap.Store(saddr, conn)
log.Println("[UDP] Created new connection for client " + saddr)
c.LogMsg("[UDP] Created new connection for client "+saddr, nil)
// Fire up routine to manage new connection
go c.RunUDPConnectionRelay(conn, lisener)
} else {
log.Println("[UDP] Found connection for client " + saddr)
c.LogMsg("[UDP] Found connection for client "+saddr, nil)
conn = rawConn.(*udpClientServerConn)
}

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

@@ -20,17 +20,26 @@ type CertCache struct {
PriKey string
}
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 map[string]string //Preferred certificate for this server name, if empty, use the first matching certificate
}
type Manager struct {
CertStore string //Path where all the certs are stored
LoadedCerts []*CertCache //A list of loaded certs
Logger *logger.Logger //System wide logger for debug mesage
verbal bool
/* External handlers */
hostSpecificTlsBehavior func(serverName string) (*HostSpecificTlsBehavior, error) // Function to get host specific TLS behavior, if nil, use global TLS options
}
//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)
}
@@ -50,10 +59,10 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager,
}
thisManager := Manager{
CertStore: certStore,
LoadedCerts: []*CertCache{},
verbal: verbal,
Logger: logger,
CertStore: certStore,
LoadedCerts: []*CertCache{},
hostSpecificTlsBehavior: defaultHostSpecificTlsBehavior, //Default to no SNI and no auto HTTPS
Logger: logger,
}
err := thisManager.UpdateLoadedCertList()
@@ -64,6 +73,25 @@ func NewManager(certStore string, verbal bool, logger *logger.Logger) (*Manager,
return &thisManager, nil
}
// Default host specific TLS behavior
// This is used when no specific TLS behavior is defined for a server name
func GetDefaultHostSpecificTlsBehavior() *HostSpecificTlsBehavior {
return &HostSpecificTlsBehavior{
DisableSNI: false,
DisableLegacyCertificateMatching: false,
EnableAutoHTTPS: false,
PreferredCertificate: map[string]string{}, // No preferred certificate, use the first matching certificate
}
}
func defaultHostSpecificTlsBehavior(serverName string) (*HostSpecificTlsBehavior, error) {
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
@@ -161,24 +189,11 @@ func (m *Manager) ListCerts() ([]string, error) {
// Get a certificate from disk where its certificate matches with the helloinfo
func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, error) {
//Check if the domain corrisponding cert exists
pubKey := "./tmp/localhost.pem"
priKey := "./tmp/localhost.key"
if utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".pem")) && utils.FileExists(filepath.Join(m.CertStore, helloInfo.ServerName+".key")) {
//Direct hit
pubKey = filepath.Join(m.CertStore, helloInfo.ServerName+".pem")
priKey = filepath.Join(m.CertStore, helloInfo.ServerName+".key")
} else if m.CertMatchExists(helloInfo.ServerName) {
//Use x509
pubKey, priKey = m.GetCertByX509CNHostname(helloInfo.ServerName)
} else {
//Fallback to legacy method of matching certificates
if m.DefaultCertExists() {
//Use default.pem and default.key
pubKey = filepath.Join(m.CertStore, "default.pem")
priKey = filepath.Join(m.CertStore, "default.key")
}
//Look for the certificate by hostname
pubKey, priKey, err := m.GetCertificateByHostname(helloInfo.ServerName)
if err != nil {
m.Logger.PrintAndLog("tls-router", "Failed to get certificate for "+helloInfo.ServerName, err)
return nil, err
}
//Load the cert and serve it
@@ -190,6 +205,55 @@ func (m *Manager) GetCert(helloInfo *tls.ClientHelloInfo) (*tls.Certificate, err
return &cer, nil
}
// GetCertificateByHostname returns the certificate and private key for a given hostname
func (m *Manager) GetCertificateByHostname(hostname string) (string, string, error) {
//Check if the domain corrisponding cert exists
pubKey := "./tmp/localhost.pem"
priKey := "./tmp/localhost.key"
tlsBehavior, err := m.hostSpecificTlsBehavior(hostname)
if err != nil {
tlsBehavior, _ = defaultHostSpecificTlsBehavior(hostname)
}
preferredCertificate, ok := tlsBehavior.PreferredCertificate[hostname]
if !ok {
preferredCertificate = ""
}
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, preferredCertificate+".pem")
priKey = filepath.Join(m.CertStore, preferredCertificate+".key")
} else {
if !tlsBehavior.DisableLegacyCertificateMatching &&
utils.FileExists(filepath.Join(m.CertStore, hostname+".pem")) &&
utils.FileExists(filepath.Join(m.CertStore, hostname+".key")) {
//Legacy filename matching, use the file names directly
//This is the legacy method of matching certificates, it will match the file names directly
//This is used for compatibility with Zoraxy v2 setups
pubKey = filepath.Join(m.CertStore, hostname+".pem")
priKey = filepath.Join(m.CertStore, hostname+".key")
} else if !tlsBehavior.DisableSNI &&
m.CertMatchExists(hostname) {
//SNI scan match, find the first matching certificate
pubKey, priKey = m.GetCertByX509CNHostname(hostname)
} else if tlsBehavior.EnableAutoHTTPS {
//Get certificate from CA, WIP
//TODO: Implement AutoHTTPS
} else {
//Fallback to legacy method of matching certificates
if m.DefaultCertExists() {
//Use default.pem and default.key
pubKey = filepath.Join(m.CertStore, "default.pem")
priKey = filepath.Join(m.CertStore, "default.key")
}
}
}
return pubKey, priKey, nil
}
// Check if both the default cert public key and private key exists
func (m *Manager) DefaultCertExists() bool {
return utils.FileExists(filepath.Join(m.CertStore, "default.pem")) && utils.FileExists(filepath.Join(m.CertStore, "default.key"))
@@ -220,7 +284,6 @@ func (m *Manager) RemoveCert(domain string) error {
//Update the cert list
m.UpdateLoadedCertList()
return nil
}

View File

@@ -69,6 +69,12 @@ func (ws *WebServer) HandlePortChange(w http.ResponseWriter, r *http.Request) {
return
}
// Check if newPort is a valid TCP port number (1-65535)
if newPort < 1 || newPort > 65535 {
utils.SendErrorResponse(w, "invalid port number given")
return
}
err = ws.ChangePort(strconv.Itoa(newPort))
if err != nil {
utils.SendErrorResponse(w, err.Error())
@@ -106,6 +112,17 @@ func (ws *WebServer) SetDisableListenToAllInterface(w http.ResponseWriter, r *ht
utils.SendErrorResponse(w, "unable to save setting")
return
}
// Update the option in the web server instance
ws.option.DisableListenToAllInterface = disableListen
// If the server is running and the setting is changed, we need to restart the server
if ws.IsRunning() {
err = ws.Restart()
if err != nil {
utils.SendErrorResponse(w, "unable to restart web server: "+err.Error())
return
}
}
utils.SendOK(w)
}

View File

@@ -210,6 +210,27 @@ func (ws *WebServer) Stop() error {
return nil
}
func (ws *WebServer) Restart() error {
if ws.isRunning {
if err := ws.Stop(); err != nil {
return err
}
}
if err := ws.Start(); err != nil {
return err
}
ws.option.Logger.PrintAndLog("static-webserv", "Static Web Server restarted. Listening on :"+ws.option.Port, nil)
return nil
}
func (ws *WebServer) IsRunning() bool {
ws.mu.Lock()
defer ws.mu.Unlock()
return ws.isRunning
}
// UpdateDirectoryListing enables or disables directory listing.
func (ws *WebServer) UpdateDirectoryListing(enable bool) {
ws.option.EnableDirectoryListing = enable

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

@@ -15,6 +15,7 @@ import (
"imuslab.com/zoraxy/mod/dynamicproxy/permissionpolicy"
"imuslab.com/zoraxy/mod/dynamicproxy/rewrite"
"imuslab.com/zoraxy/mod/netutils"
"imuslab.com/zoraxy/mod/tlscert"
"imuslab.com/zoraxy/mod/uptime"
"imuslab.com/zoraxy/mod/utils"
)
@@ -134,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
}
}
@@ -334,7 +335,8 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
tags = filteredTags
var proxyEndpointCreated *dynamicproxy.ProxyEndpoint
if eptype == "host" {
switch eptype {
case "host":
rootOrMatchingDomain, err := utils.PostPara(r, "rootname")
if err != nil {
utils.SendErrorResponse(w, "hostname not defined")
@@ -415,7 +417,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
dynamicProxyRouter.AddProxyRouteToRuntime(preparedEndpoint)
proxyEndpointCreated = &thisProxyEndpoint
} else if eptype == "root" {
case "root":
//Get the default site options and target
dsOptString, err := utils.PostPara(r, "defaultSiteOpt")
if err != nil {
@@ -469,7 +471,7 @@ func ReverseProxyHandleAddEndpoint(w http.ResponseWriter, r *http.Request) {
return
}
proxyEndpointCreated = &rootRoutingEndpoint
} else {
default:
//Invalid eptype
utils.SendErrorResponse(w, "invalid endpoint type")
return
@@ -677,6 +679,70 @@ func ReverseProxyHandleAlias(w http.ResponseWriter, r *http.Request) {
utils.SendOK(w)
}
func ReverseProxyHandleSetTlsConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
utils.SendErrorResponse(w, "Method not supported")
return
}
rootnameOrMatchingDomain, err := utils.PostPara(r, "ep")
if err != nil {
utils.SendErrorResponse(w, "Invalid ep given")
return
}
tlsConfig, err := utils.PostPara(r, "tlsConfig")
if err != nil {
utils.SendErrorResponse(w, "Invalid TLS config given")
return
}
tlsConfig = strings.TrimSpace(tlsConfig)
if tlsConfig == "" {
utils.SendErrorResponse(w, "TLS config cannot be empty")
return
}
newTlsConfig := &tlscert.HostSpecificTlsBehavior{}
err = json.Unmarshal([]byte(tlsConfig), newTlsConfig)
if err != nil {
utils.SendErrorResponse(w, "Invalid TLS config given: "+err.Error())
return
}
//Load the target endpoint
ept, err := dynamicProxyRouter.LoadProxy(rootnameOrMatchingDomain)
if err != nil {
utils.SendErrorResponse(w, err.Error())
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
readyRoutingRule, err := dynamicProxyRouter.PrepareProxyRoute(ept)
if err != nil {
utils.SendErrorResponse(w, err.Error())
return
}
dynamicProxyRouter.AddProxyRouteToRuntime(readyRoutingRule)
//Save it to file
err = SaveReverseProxyConfig(ept)
if err != nil {
utils.SendErrorResponse(w, "Failed to save TLS config: "+err.Error())
return
}
utils.SendOK(w)
}
func ReverseProxyHandleSetHostname(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
utils.SendErrorResponse(w, "Method not supported")
@@ -1015,6 +1081,7 @@ func RemoveProxyBasicAuthExceptionPaths(w http.ResponseWriter, r *http.Request)
func ReverseProxyStatus(w http.ResponseWriter, r *http.Request) {
js, err := json.Marshal(dynamicProxyRouter)
if err != nil {
SystemWideLogger.PrintAndLog("proxy-config", "Unable to marshal status data", err)
utils.SendErrorResponse(w, "Unable to marshal status data")
return
}

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

@@ -338,10 +338,45 @@
<!-- TLS / SSL -->
<div class="rpconfig_content" rpcfg="ssl">
<div class="ui segment">
<p>Work In Progress <br>
Please use the outer-most menu TLS / SSL tab for now. </p>
<p>The table below shows which certificate will be served by Zoraxy when a client request the following hostnames.</p>
<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 class="no-sort">Resolve to Certificate</th>
</tr>
</thead>
<tbody>
<!-- Rows will be dynamically populated -->
</tbody>
</table>
<div class="ui checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="Tls_EnableSNI">
<label>Enable SNI<br>
<small>Resolve Server Name Indication (SNI) and automatically select a certificate</small>
</label>
</div>
<div class="ui checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="Tls_EnableLegacyCertificateMatching">
<label>Enable Legacy Certificate Matching<br>
<small>Use filename for hostname matching, faster but less accurate</small>
</label>
</div>
<div class="ui disabled checkbox" style="margin-top: 0.4em;">
<input type="checkbox" class="Tls_EnableAutoHTTPS">
<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 -->
@@ -551,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);
@@ -567,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>
@@ -711,6 +768,112 @@
$("#httpProxyList").find(".editBtn").removeClass("disabled");
}
function saveTlsConfigs(uuid){
let enableSNI = $("#httprpEditModal .Tls_EnableSNI")[0].checked;
let enableLegacyCertificateMatching = $("#httprpEditModal .Tls_EnableLegacyCertificateMatching")[0].checked;
let enableAutoHTTPS = $("#httprpEditModal .Tls_EnableAutoHTTPS")[0].checked;
let newTlsOption = {
"DisableSNI": !enableSNI,
"DisableLegacyCertificateMatching": !enableLegacyCertificateMatching,
"EnableAutoHTTPS": enableAutoHTTPS,
}
$.cjax({
url: "/api/proxy/setTlsConfig",
method: "POST",
data: {
"ep": uuid,
"tlsConfig": JSON.stringify(newTlsOption)
},
success: function(data){
if (data.error !== undefined){
msgbox(data.error, false, 3000);
}else{
msgbox("TLS Config updated");
}
updateTlsResolveList(uuid);
}
});
}
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,
method: "GET",
success: function(data) {
// Populate the TLS resolve list
let resolveList = editor.find(".Tls_resolve_list tbody");
resolveList.empty(); // Clear existing entries
let primaryDomain = data.domain;
let aliasDomains = data.alias_domains || [];
let certMap = data.domain_key_pair;
// Add primary domain entry
resolveList.append(`
<tr>
<td>${primaryDomain}</td>
<td class="certificateDropdown" domain="${primaryDomain}">${certMap[primaryDomain] || "Fallback Certificate"}</td>
</tr>
`);
aliasDomains.forEach(alias => {
resolveList.append(`
<tr>
<td>${alias}</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();
}
});
}
});
}
function saveProxyInlineEdit(uuid){
let editor = $("#httprpEditModal");
@@ -857,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"){
@@ -985,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));
@@ -1086,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));
@@ -1148,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 ------------ */
@@ -1245,6 +1422,60 @@
editor.find(".RateLimit").off("change").on("change", rateLimitChangeEvent);
/* ------------ 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);
editor.find(".Tls_EnableSNI").off("change").on("change", function() {
saveTlsConfigs(uuid);
});
editor.find(".Tls_EnableLegacyCertificateMatching").off("change").on("change", function() {
saveTlsConfigs(uuid);
});
editor.find(".Tls_EnableAutoHTTPS").off("change").on("change", function() {
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 ------------ */
(()=>{
@@ -1308,7 +1539,6 @@
});
}
/*
Page Initialization Functions
*/
@@ -1333,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

File diff suppressed because it is too large Load Diff

View File

@@ -3,18 +3,15 @@
<h2>SSO</h2>
<p>Single Sign-On (SSO) and authentication providers settings </p>
</div>
<div class="ui basic segment">
<div class="ui yellow message">
<div class="header">
Experimental Feature
</div>
<p>Please note that this feature is still in development and may not work as expected.</p>
</div>
</div>
<div class="ui divider"></div>
<div class="ui basic segment">
<h3>Forward Auth</h3>
<div class="ui top attached tabular menu ssoTabs">
<a class="item active" data-tab="forward_auth_tab">Forward Auth</a>
<a class="item" data-tab="oauth2_tab">Oauth2</a>
<!-- <a class="item" data-tab="zoraxy_sso_tab">Zoraxy SSO</a> -->
</div>
<div class="ui bottom attached tab segment active" data-tab="forward_auth_tab">
<!-- Forward Auth -->
<h2>Forward Auth</h2>
<p>Configuration settings for the Forward Auth provider.</p>
<p>The Forward Auth provider makes a subrequest to an authorization server that supports Forward Auth, then either:</p>
<ul>
@@ -86,10 +83,10 @@
</div>
<button class="ui basic button" type="submit"><i class="green check icon"></i> Apply Change</button>
</form>
</div>
<div class="ui divider"></div>
<div class="ui basic segment">
<h3>OAuth 2.0</h3>
</div>
<div class="ui bottom attached tab segment" data-tab="oauth2_tab">
<!-- Oauth 2 -->
<h2>OAuth 2.0</h2>
<p>Configuration settings for OAuth 2.0 authentication provider.</p>
<form class="ui form" action="#" id="oauth2Settings">
@@ -134,11 +131,18 @@
</div>
<button class="ui basic button" type="submit"><i class="green check icon"></i> Apply Change</button>
</form>
</div>
<div class="ui divider"></div>
</div>
<div class="ui bottom attached tab segment" data-tab="zoraxy_sso_tab">
<!-- Zoraxy SSO -->
<h3>Zoraxy SSO</h3>
<p>Configuration settings for Zoraxy SSO provider.</p>
<p>Currently not implemented.</p>
</div>
</div>
<script>
$(".ssoTabs .item").tab();
$(document).ready(function() {
/* Load forward-auth settings from backend */
$.cjax({
@@ -147,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

@@ -16,6 +16,7 @@
<th>Target Address</th>
<th>Mode</th>
<th>Timeout (s)</th>
<th>Enable Logging</th>
<th>Actions</th>
</tr>
</thead>
@@ -73,6 +74,22 @@
<small>Forward UDP request on this listening socket</small></label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" tabindex="0" name="useProxyProtocol" class="hidden">
<label>Enable Proxy Protocol V1<br>
<small>Enable TCP Proxy Protocol header V1</small>
</label>
</div>
</div>
<div class="field">
<div class="ui toggle checkbox">
<input type="checkbox" tabindex="0" name="enableLogging" class="hidden">
<label>Enable Logging<br>
<small>Enable logging of connection status and errors for this rule</small>
</label>
</div>
</div>
<button id="addStreamProxyButton" class="ui basic button" type="submit"><i class="ui green add icon"></i> Create</button>
<button id="editStreamProxyButton" class="ui basic button" onclick="confirmEditTCPProxyConfig(event, this);" style="display:none;"><i class="ui green check icon"></i> Update</button>
<button class="ui basic red button" onclick="event.preventDefault(); cancelStreamProxyEdit(event);"><i class="ui red remove icon"></i> Cancel</button>
@@ -120,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");
@@ -195,6 +212,10 @@
modeText.push("UDP")
}
if (config.UseProxyProtocol){
modeText.push("ProxyProtocol V1")
}
modeText = modeText.join(" & ")
var thisConfig = encodeURIComponent(JSON.stringify(config));
@@ -207,6 +228,10 @@
row.append($('<td>').text(config.ProxyTargetAddr));
row.append($('<td>').text(modeText));
row.append($('<td>').text(config.Timeout));
row.append($('<td>').html(config.EnableLogging ?
'<i class="green check icon" title="Logging Enabled"></i>' :
'<i class="red times icon" title="Logging Disabled"></i>'
));
row.append($('<td>').html(`
${startButton}
<button onclick="editTCPProxyConfig('${config.UUID}');" class="ui circular basic mini icon button" title="Edit Config"><i class="edit icon"></i></button>
@@ -252,6 +277,22 @@
$(checkboxEle).checkbox("set unchecked");
}
return;
}else if (key == "UseProxyProtocol"){
let checkboxEle = $("#streamProxyForm input[name=useProxyProtocol]").parent();
if (value === true){
$(checkboxEle).checkbox("set checked");
}else{
$(checkboxEle).checkbox("set unchecked");
}
return;
}else if (key == "EnableLogging"){
let checkboxEle = $("#streamProxyForm input[name=enableLogging]").parent();
if (value === true){
$(checkboxEle).checkbox("set checked");
}else{
$(checkboxEle).checkbox("set unchecked");
}
return;
}else if (key == "ListeningAddress"){
field = $("#streamProxyForm input[name=listenAddr]");
}else if (key == "ProxyTargetAddr"){
@@ -301,6 +342,8 @@
proxyAddr: $("#streamProxyForm input[name=proxyAddr]").val().trim(),
useTCP: $("#streamProxyForm input[name=useTCP]")[0].checked ,
useUDP: $("#streamProxyForm input[name=useUDP]")[0].checked ,
useProxyProtocol: $("#streamProxyForm input[name=useProxyProtocol]")[0].checked ,
enableLogging: $("#streamProxyForm input[name=enableLogging]")[0].checked ,
timeout: parseInt($("#streamProxyForm input[name=timeout]").val().trim()),
},
success: function(response) {

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);
}
});
}