feat: initial commit of Header Tagger plugin

This commit is contained in:
Claus Lohmar 2026-10-14 13:51:59 +00:00
commit e8a3ed3742
12 changed files with 1139 additions and 0 deletions

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# Binary
zoraxy-headers
# Go
*.exe
*.test
*.out
go.sum
# IDE
.idea/
.vscode/
*.swp
# OS
.DS_Store
Thumbs.db
# Temp
tmp/
.tmp/

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# Header Tagger
**Zoraxy plugin** — Copy custom headers from a source domain to every other domain sharing the same tag.
---
## The Problem
Zoraxy forwards `X-Forwarded-For`, `X-Real-IP`, `X-Forwarded-Proto`, and `X-Forwarded-Host` natively, but when you want a *custom* header on many domains (e.g. `X-Original-Client-IP = $remote_addr`, or a security header), you'd normally set it manually on every domain — one by one.
## The Solution
Define the header **once** on a source domain, tag it, then let this plugin copy it to all other domains sharing the tag.
```
Tag "tracking":
├── app.example.com ← manually set X-Original-Client-IP = $remote_addr (SOURCE)
├── api.example.com ← plugin copies the header here
└── cdn.example.com ← and here
```
---
## Usage
1. In Zoraxy, edit a domain → **Advanced Settings → Custom Headers** → add your headers.
2. Tag that domain (e.g. `tracking`).
3. Tag the other domains with the same tag.
4. Open the plugin at `/plugin.ui/zoraxy-headers/`.
5. Click **Apply to selected** — the plugin copies the source's headers to the selected targets.
Use **Remove from selected** to strip those headers from targets.
---
## Supported Dynamic Header Values
Zoraxy v3.1.2+ supports nginx-style `$variable` placeholders in request-direction header values:
| Placeholder | Meaning |
|-------------|---------|
| `$remote_addr` | Client IP |
| `$host` | Original host |
| `$scheme` | http / https |
| `$request_uri` | Full request URI |
| `$request_method` | GET / POST / ... |
| `$http_user_agent` | User-Agent |
| `$http_referer` | Referer |
Example — preserve the original client IP in a custom header:
```
Header: X-Original-Client-IP = $remote_addr (request direction)
```
---
## Install
```bash
curl -sSL https://git.lohmar.co.uk/cclohmar/zoraxy-headers/raw/branch/main/install.sh | sudo bash
```
Or manually:
```bash
git clone https://git.lohmar.co.uk/cclohmar/zoraxy-headers.git /opt/zoraxy/plugins/zoraxy-headers
cd /opt/zoraxy/plugins/zoraxy-headers
go build -buildvcs=false -o zoraxy-headers .
systemctl restart zoraxy
```
Then enable **Header Tagger** in Zoraxy → Plugins.
---
## How It Works
The plugin uses Zoraxy's plugin API key (scoped to proxy list + header endpoints) to:
1. `GET /api/proxy/list?type=host` → all domains and their tags
2. Group domains by tag, identify the "source" (a domain that already has headers)
3. `POST /api/proxy/header/add` / `remove` → copy the source's headers to targets
No root access needed — the API key is scoped to only the required endpoints.
---
## API Endpoints (plugin UI)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/ui/api/groups` | List tags → domains → source |
| `POST` | `/ui/api/apply` | Copy source headers to targets |
| `POST` | `/ui/api/remove` | Remove headers from targets |
---
## Requirements
- Zoraxy v3.2.0+
- Go 1.21+ (to build)
---
## License
Zoraxy plugin SDK is LGPL. This plugin is open source.

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module zoraxy-headers
go 1.21

67
install.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
#==============================================================================
# Header Tagger — Zoraxy Plugin Installer
# Clones, builds, and installs the zoraxy-headers plugin.
#==============================================================================
set -euo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*"; }
PLUGIN_REPO="https://git.lohmar.co.uk/cclohmar/zoraxy-headers.git"
PLUGIN_DIR="/opt/zoraxy/plugins/zoraxy-headers"
# Ensure root
if [[ $EUID -ne 0 ]]; then
warn "Requires root. Restarting with sudo..."
sudo bash "$0" "$@"
exit $?
fi
# Detect OS + install deps
if [ -f /etc/os-release ]; then . /etc/os-release; else OS_ID="unknown"; fi
log "Detected: $OS_ID"
case "$OS_ID" in
debian|ubuntu|raspbian|linuxmint)
apt-get update -qq && apt-get install -y -qq git curl golang-go ;;
rhel|centos|fedora|rocky|almalinux|amzn)
dnf install -y git curl golang 2>/dev/null || yum install -y git curl golang ;;
alpine) apk add --no-cache git curl go ;;
arch|manjaro) pacman -S --noconfirm git curl go ;;
*) warn "Unsupported OS — install git, curl, go manually." ;;
esac
# Clone / pull
if [ -d "$PLUGIN_DIR/.git" ]; then
log "Updating existing clone..."
git -C "$PLUGIN_DIR" pull origin main 2>/dev/null || true
else
log "Cloning plugin..."
mkdir -p "$(dirname "$PLUGIN_DIR")"
git clone "$PLUGIN_REPO" "$PLUGIN_DIR"
fi
# Build
log "Building..."
(cd "$PLUGIN_DIR" && go build -buildvcs=false -o zoraxy-headers .)
# Ownership
if id zoraxy &>/dev/null; then
chown -R zoraxy:zoraxy "$PLUGIN_DIR"
fi
# Restart Zoraxy to discover the plugin
log "Restarting Zoraxy..."
systemctl restart zoraxy 2>/dev/null || true
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN} ✅ Header Tagger installed!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e " Enable it in Zoraxy → Plugins → Header Tagger"
echo -e " Access at: /plugin.ui/zoraxy-headers/"
echo ""

70
main.go Normal file
View file

@ -0,0 +1,70 @@
package main
import (
"embed"
"fmt"
"log"
"net/http"
"zoraxy-headers/zoraxy_plugin"
)
//go:embed web/*
var webFS embed.FS
func main() {
spec := &zoraxy_plugin.IntroSpect{
ID: "zoraxy-headers",
Name: "Header Tagger",
Author: "Zoraxy Community",
AuthorContact: "",
Description: "Copy custom headers from a source domain to all other domains sharing the same tag.",
URL: "",
Type: zoraxy_plugin.PluginType_Utilities,
VersionMajor: 1,
VersionMinor: 0,
VersionPatch: 0,
UIPath: "/ui",
PermittedAPIEndpoints: []zoraxy_plugin.PermittedAPIEndpoint{
{Method: "GET", Endpoint: "/api/proxy/list", Reason: "List proxy rules and their tags"},
{Method: "GET", Endpoint: "/api/proxy/header/list", Reason: "Read existing custom headers on a domain"},
{Method: "POST", Endpoint: "/api/proxy/header/add", Reason: "Copy headers onto tagged domains"},
{Method: "POST", Endpoint: "/api/proxy/header/remove", Reason: "Remove copied headers from tagged domains"},
},
}
config, err := zoraxy_plugin.ServeAndRecvSpec(spec)
if err != nil {
log.Fatalf("failed to receive config: %v", err)
}
// Build the Zoraxy API client using the scoped key and Zoraxy port.
baseURL := "http://127.0.0.1:8000"
if config.ZoraxyPort > 0 {
baseURL = fmt.Sprintf("http://127.0.0.1:%d", config.ZoraxyPort)
}
client := NewZoraxyClient(baseURL, config.APIKey)
handler := &Handler{client: client}
mux := http.NewServeMux()
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(spec.ID, &webFS, "web", spec.UIPath)
uiRouter.HandleFunc("/api/groups", handler.handleGroups, mux)
uiRouter.HandleFunc("/api/apply", handler.handleApply, mux)
uiRouter.HandleFunc("/api/remove", handler.handleRemove, mux)
uiRouter.RegisterTerminateHandler(func() {
log.Println("zoraxy-headers shutting down")
}, mux)
uiRouter.AttachHandlerToMux(mux)
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
log.Printf("zoraxy-headers v%d.%d.%d listening on %s (zoraxy API %s)",
spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr, baseURL)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}

52
models.go Normal file
View file

@ -0,0 +1,52 @@
package main
// HeaderRule is a single user-defined header from Zoraxy's API.
type HeaderRule struct {
Direction int `json:"Direction"`
Key string `json:"Key"`
Value string `json:"Value"`
IsRemove bool `json:"IsRemove"`
}
// ProxyRule is a proxy rule entry from GET /api/proxy/list?type=host.
type ProxyRule struct {
RootOrMatchingDomain string `json:"RootOrMatchingDomain"`
Tags []string `json:"Tags"`
}
// TagGroup groups the domains that share a tag, plus the identified source.
type TagGroup struct {
Tag string `json:"tag"`
Domains []string `json:"domains"`
Source string `json:"source"` // domain that already has headers (template)
HasSource bool `json:"has_source"`
}
// TagGroupsResponse is the payload sent to the frontend.
type TagGroupsResponse struct {
Groups []TagGroup `json:"groups"`
}
// ApplyRequest asks the plugin to copy the source domain's headers to targets.
type ApplyRequest struct {
Tag string `json:"tag"`
Source string `json:"source"`
Targets []string `json:"targets"`
}
// ActionResult reports per-domain success/failure.
type ActionResult struct {
Domain string `json:"domain"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// ApplyResponse is returned after an apply/remove operation.
type ApplyResponse struct {
Results []ActionResult `json:"results"`
}
// ErrorResponse is a generic error.
type ErrorResponse struct {
Error string `json:"error"`
}

284
server.go Normal file
View file

@ -0,0 +1,284 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sort"
"strings"
)
// ZoraxyClient wraps the Zoraxy management API with a scoped API key.
type ZoraxyClient struct {
BaseURL string // e.g. http://127.0.0.1:8000
APIKey string
HTTP *http.Client
}
func NewZoraxyClient(baseURL, apiKey string) *ZoraxyClient {
return &ZoraxyClient{BaseURL: baseURL, APIKey: apiKey, HTTP: &http.Client{}}
}
func (c *ZoraxyClient) do(method, path string, form url.Values) ([]byte, error) {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, c.BaseURL+path, body)
if err != nil {
return nil, err
}
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("zoraxy API %s %s: HTTP %d — %s", method, path, resp.StatusCode, string(data))
}
return data, nil
}
// listProxyRules returns all host-type proxy rules.
func (c *ZoraxyClient) listProxyRules() ([]ProxyRule, error) {
data, err := c.do("GET", "/api/proxy/list?type=host", nil)
if err != nil {
return nil, err
}
var rules []ProxyRule
if err := json.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("parse proxy list: %w", err)
}
return rules, nil
}
// listHeaders returns the user-defined headers for one domain.
func (c *ZoraxyClient) listHeaders(domain string) ([]HeaderRule, error) {
q := url.Values{}
q.Set("type", "host")
q.Set("domain", domain)
data, err := c.do("GET", "/api/proxy/header/list?"+q.Encode(), nil)
if err != nil {
return nil, err
}
var headers []HeaderRule
if err := json.Unmarshal(data, &headers); err != nil {
return nil, fmt.Errorf("parse header list for %s: %w", domain, err)
}
return headers, nil
}
// addHeader adds (or overwrites) a header on a domain.
func (c *ZoraxyClient) addHeader(domain string, direction int, name, value string) error {
form := url.Values{}
form.Set("type", "add")
form.Set("domain", domain)
form.Set("direction", fmt.Sprintf("%d", direction))
form.Set("name", name)
form.Set("value", value)
_, err := c.do("POST", "/api/proxy/header/add", form)
return err
}
// removeHeader removes a header by name from a domain.
func (c *ZoraxyClient) removeHeader(domain, name string) error {
form := url.Values{}
form.Set("domain", domain)
form.Set("name", name)
_, err := c.do("POST", "/api/proxy/header/remove", form)
return err
}
// buildTagGroups groups domains by tag and identifies the source (a domain that
// already has user-defined headers) for each tag.
func (c *ZoraxyClient) buildTagGroups() ([]TagGroup, error) {
rules, err := c.listProxyRules()
if err != nil {
return nil, err
}
// Collect domains per tag.
tagDomains := map[string][]string{}
for _, r := range rules {
for _, tag := range r.Tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
tagDomains[tag] = append(tagDomains[tag], r.RootOrMatchingDomain)
}
}
groups := make([]TagGroup, 0, len(tagDomains))
for tag, domains := range tagDomains {
sort.Strings(domains)
group := TagGroup{Tag: tag, Domains: domains, Source: "", HasSource: false}
// Identify source: the first domain that already has headers.
for _, d := range domains {
headers, err := c.listHeaders(d)
if err != nil {
log.Printf("WARNING: list headers for %s: %v", d, err)
continue
}
if len(headers) > 0 {
group.Source = d
group.HasSource = true
break
}
}
groups = append(groups, group)
}
sort.Slice(groups, func(i, j int) bool { return groups[i].Tag < groups[j].Tag })
return groups, nil
}
// applyHeaders copies the source domain's headers to all target domains.
func (c *ZoraxyClient) applyHeaders(tag, source string, targets []string) []ActionResult {
results := []ActionResult{}
// Read the source headers once.
srcHeaders, err := c.listHeaders(source)
if err != nil {
results = append(results, ActionResult{Domain: source, Success: false, Error: err.Error()})
return results
}
if len(srcHeaders) == 0 {
results = append(results, ActionResult{Domain: source, Success: false, Error: "source has no headers to copy"})
return results
}
for _, t := range targets {
if t == source {
continue
}
var firstErr error
for _, h := range srcHeaders {
if h.IsRemove {
if err := c.removeHeader(t, h.Key); err != nil {
firstErr = err
}
} else {
if err := c.addHeader(t, h.Direction, h.Key, h.Value); err != nil {
firstErr = err
}
}
}
if firstErr != nil {
results = append(results, ActionResult{Domain: t, Success: false, Error: firstErr.Error()})
} else {
results = append(results, ActionResult{Domain: t, Success: true})
}
}
return results
}
// removeHeaders removes the source's header keys from all target domains.
func (c *ZoraxyClient) removeHeaders(source string, targets []string) []ActionResult {
results := []ActionResult{}
srcHeaders, err := c.listHeaders(source)
if err != nil {
results = append(results, ActionResult{Domain: source, Success: false, Error: err.Error()})
return results
}
for _, t := range targets {
if t == source {
continue
}
var firstErr error
for _, h := range srcHeaders {
if err := c.removeHeader(t, h.Key); err != nil {
firstErr = err
}
}
if firstErr != nil {
results = append(results, ActionResult{Domain: t, Success: false, Error: firstErr.Error()})
} else {
results = append(results, ActionResult{Domain: t, Success: true})
}
}
return results
}
// ---------------------------------------------------------------------------
// HTTP handlers
// ---------------------------------------------------------------------------
type Handler struct {
client *ZoraxyClient
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (h *Handler) handleGroups(w http.ResponseWriter, r *http.Request) {
groups, err := h.client.buildTagGroups()
if err != nil {
log.Printf("ERROR build tag groups: %v", err)
writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to load tags"})
return
}
writeJSON(w, http.StatusOK, TagGroupsResponse{Groups: groups})
}
func (h *Handler) handleApply(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid form"})
return
}
tag := r.FormValue("tag")
source := r.FormValue("source")
targets := r.Form["targets"]
if tag == "" || source == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "tag and source required"})
return
}
if len(targets) == 0 {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "no target domains"})
return
}
results := h.client.applyHeaders(tag, source, targets)
writeJSON(w, http.StatusOK, ApplyResponse{Results: results})
}
func (h *Handler) handleRemove(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid form"})
return
}
source := r.FormValue("source")
targets := r.Form["targets"]
if source == "" {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "source required"})
return
}
if len(targets) == 0 {
writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "no target domains"})
return
}
results := h.client.removeHeaders(source, targets)
writeJSON(w, http.StatusOK, ApplyResponse{Results: results})
}

143
web/app.js Normal file
View file

@ -0,0 +1,143 @@
/**
* Header Tagger — client-side logic.
* Uses Zoraxy's $.cjax() for CSRF-safe AJAX.
*/
function showToast(message, type) {
var toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast ' + (type || '');
toast.classList.remove('hidden');
setTimeout(function () { toast.classList.add('hidden'); }, 4000);
}
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escAttr(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function renderGroups(data) {
var container = document.getElementById('groups');
var groups = data.groups || [];
if (groups.length === 0) {
container.innerHTML = '<div class="empty">No tagged domains found. Tag a domain in Zoraxy (e.g. "tracking") and refresh.</div>';
return;
}
var html = groups.map(function (g) {
var domainsHtml = g.domains.map(function (d) {
var isSource = (d === g.source);
if (isSource) {
return '<li class="domain source">' + esc(d) + ' <span class="badge-source">SOURCE</span></li>';
}
return '<li class="domain"><label><input type="checkbox" class="target-cb" data-domain="' + escAttr(d) + '" checked> ' + esc(d) + '</label></li>';
}).join('');
var noSource = !g.hasSource
? '<div class="warn">No domain in this tag has headers yet. Set headers on one domain (via Zoraxy UI) to use it as the source.</div>'
: '';
return (
'<div class="group" data-tag="' + escAttr(g.tag) + '" data-source="' + escAttr(g.source) + '">' +
'<h2 class="group-title">' + esc(g.tag) + '</h2>' +
noSource +
'<ul class="domain-list">' + domainsHtml + '</ul>' +
'<div class="group-actions">' +
'<button class="btn btn-primary btn-apply">Apply to selected</button>' +
'<button class="btn btn-secondary btn-remove">Remove from selected</button>' +
'</div>' +
'<div class="result"></div>' +
'</div>'
);
}).join('');
container.innerHTML = html;
}
function loadGroups() {
$.get('./api/groups', function (data) {
renderGroups(data);
}).fail(function () {
showToast('Failed to load tags', 'error');
});
}
function getSelectedTargets(group) {
var targets = [];
group.querySelectorAll('.target-cb:checked').forEach(function (cb) {
targets.push(cb.dataset.domain);
});
return targets;
}
function applyGroup(group) {
var tag = group.dataset.tag;
var source = group.dataset.source;
var targets = getSelectedTargets(group);
var resultEl = group.querySelector('.result');
if (!source) { showToast('No source domain with headers', 'error'); return; }
if (targets.length === 0) { showToast('No target domains selected', 'error'); return; }
resultEl.textContent = 'Applying...';
$.cjax({
url: './api/apply',
type: 'POST',
dataType: 'json',
data: { tag: tag, source: source, targets: targets },
success: function (data) {
var ok = (data.results || []).filter(function (r) { return r.success; }).length;
var fail = (data.results || []).filter(function (r) { return !r.success; }).length;
resultEl.innerHTML = '<span class="ok">' + ok + ' applied</span>' + (fail ? ', <span class="err">' + fail + ' failed</span>' : '');
loadGroups();
},
error: function (xhr) {
var msg = 'Apply failed';
try { var r = JSON.parse(xhr.responseText); msg = r.error || r.message || msg; } catch(e) {}
resultEl.innerHTML = '<span class="err">' + esc(msg) + '</span>';
}
});
}
function removeGroup(group) {
var source = group.dataset.source;
var targets = getSelectedTargets(group);
var resultEl = group.querySelector('.result');
if (!source) { showToast('No source domain', 'error'); return; }
if (targets.length === 0) { showToast('No target domains selected', 'error'); return; }
resultEl.textContent = 'Removing...';
$.cjax({
url: './api/remove',
type: 'POST',
dataType: 'json',
data: { source: source, targets: targets },
success: function (data) {
var ok = (data.results || []).filter(function (r) { return r.success; }).length;
var fail = (data.results || []).filter(function (r) { return !r.success; }).length;
resultEl.innerHTML = '<span class="ok">' + ok + ' removed</span>' + (fail ? ', <span class="err">' + fail + ' failed</span>' : '');
loadGroups();
},
error: function (xhr) {
var msg = 'Remove failed';
try { var r = JSON.parse(xhr.responseText); msg = r.error || r.message || msg; } catch(e) {}
resultEl.innerHTML = '<span class="err">' + esc(msg) + '</span>';
}
});
}
document.getElementById('groups').addEventListener('click', function (e) {
var group = e.target.closest('.group');
if (!group) return;
if (e.target.closest('.btn-apply')) applyGroup(group);
if (e.target.closest('.btn-remove')) removeGroup(group);
});
loadGroups();

28
web/index.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="zoraxy.csrf.Token" content="{{.csrfToken}}">
<title>Header Tagger</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header>
<h1>Header Tagger</h1>
<p class="subtitle">Copy custom headers from a source domain to every other domain sharing the same tag.</p>
</header>
<main>
<div id="groups" class="groups">
<div class="loading">Loading tags...</div>
</div>
</main>
<div id="toast" class="toast hidden"></div>
<script src="/script/jquery-3.6.0.min.js"></script>
<script src="/script/utils.js"></script>
<script src="./app.js"></script>
</body>
</html>

109
web/style.css Normal file
View file

@ -0,0 +1,109 @@
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 14px;
color: #1a1a2e;
background: #f5f6fa;
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
header { margin-bottom: 20px; }
h1 { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
.subtitle { color: #636e72; font-size: 13px; }
.loading, .empty {
text-align: center;
padding: 40px;
color: #999;
}
.groups { display: flex; flex-direction: column; gap: 16px; }
.group {
background: #fff;
border-radius: 6px;
padding: 16px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.group-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 10px;
color: #0984e3;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.warn {
background: #fff3cd;
color: #856404;
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 10px;
}
.domain-list {
list-style: none;
margin-bottom: 12px;
}
.domain {
padding: 6px 0;
border-bottom: 1px solid #f0f0f5;
}
.domain:last-child { border-bottom: none; }
.domain label { cursor: pointer; }
.domain.source {
color: #00b894;
font-weight: 600;
}
.badge-source {
display: inline-block;
background: #00b894;
color: #fff;
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
margin-left: 6px;
vertical-align: middle;
}
.group-actions { display: flex; gap: 8px; margin-top: 8px; }
.btn {
padding: 6px 14px;
border: 1px solid #dcdde1;
border-radius: 4px;
background: #fff;
font-size: 13px;
cursor: pointer;
}
.btn-primary { background: #0984e3; color: #fff; border-color: #0984e3; }
.btn-primary:hover { background: #0773c5; }
.btn-secondary { background: #dfe6e9; border-color: #b2bec3; }
.btn-secondary:hover { background: #b2bec3; }
.result { margin-top: 8px; font-size: 13px; }
.ok { color: #00b894; }
.err { color: #d63031; }
.toast {
display: inline-block;
margin-left: 12px;
padding: 4px 12px;
border-radius: 3px;
font-size: 13px;
color: #fff;
transition: opacity 0.25s;
}
.toast.success { background: #00b894; }
.toast.error { background: #d63031; }
.toast.hidden { opacity: 0; pointer-events: none; }

View file

@ -0,0 +1,135 @@
package zoraxy_plugin
import (
"embed"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// PluginUiRouter serves an embedded web UI and provides CSRF token injection.
type PluginUiRouter struct {
PluginID string
TargetFs *embed.FS
TargetFsPrefix string
HandlerPrefix string
EnableDebug bool
terminateHandler func()
}
// NewPluginEmbedUIRouter creates a router backed by an embed.FS.
// targetFsPrefix is the root folder within the embed.FS (e.g. "/web").
// handlerPrefix is the HTTP path prefix (e.g. "/ui").
func NewPluginEmbedUIRouter(pluginID string, targetFs *embed.FS, targetFsPrefix string, handlerPrefix string) *PluginUiRouter {
if !strings.HasPrefix(targetFsPrefix, "/") {
targetFsPrefix = "/" + targetFsPrefix
}
targetFsPrefix = strings.TrimSuffix(targetFsPrefix, "/")
if !strings.HasPrefix(handlerPrefix, "/") {
handlerPrefix = "/" + handlerPrefix
}
handlerPrefix = strings.TrimSuffix(handlerPrefix, "/")
return &PluginUiRouter{
PluginID: pluginID,
TargetFs: targetFs,
TargetFsPrefix: targetFsPrefix,
HandlerPrefix: handlerPrefix,
}
}
// csrfMiddleware intercepts HTML responses and injects the CSRF token.
func (p *PluginUiRouter) csrfMiddleware(r *http.Request, fsHandler http.Handler) http.Handler {
csrfToken := r.Header.Get("X-Zoraxy-Csrf")
if csrfToken == "" {
csrfToken = "missing-csrf-token"
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".html") {
targetPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/")
targetPath = strings.TrimPrefix(targetPath, "/")
content, err := fs.ReadFile(*p.TargetFs, targetPath)
if err != nil {
http.Error(w, "File not found", http.StatusNotFound)
return
}
body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
if strings.HasSuffix(r.URL.Path, "/") {
indexPath := p.TargetFsPrefix + "/" + strings.TrimPrefix(r.URL.Path, "/") + "index.html"
indexPath = strings.TrimPrefix(indexPath, "/")
content, err := fs.ReadFile(*p.TargetFs, indexPath)
if err == nil {
body := strings.ReplaceAll(string(content), "{{.csrfToken}}", csrfToken)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(body))
return
}
}
fsHandler.ServeHTTP(w, r)
})
}
// Handler returns an http.Handler for the embedded UI.
func (p *PluginUiRouter) Handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rewrittenURL := strings.TrimPrefix(r.RequestURI, p.HandlerPrefix)
rewrittenURL = strings.ReplaceAll(rewrittenURL, "//", "/")
r.URL, _ = url.Parse(rewrittenURL)
r.RequestURI = rewrittenURL
subFS, err := fs.Sub(*p.TargetFs, strings.TrimPrefix(p.TargetFsPrefix, "/"))
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
p.csrfMiddleware(r, http.FileServer(http.FS(subFS))).ServeHTTP(w, r)
})
}
// RegisterTerminateHandler registers a graceful shutdown endpoint at {prefix}/term.
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() {
time.Sleep(100 * time.Millisecond)
os.Exit(0)
}()
})
}
// HandleFunc registers a handler under the UI path prefix.
func (p *PluginUiRouter) HandleFunc(pattern string, handler http.HandlerFunc, mux *http.ServeMux) {
if mux == nil {
mux = http.DefaultServeMux
}
if !strings.HasPrefix(pattern, p.HandlerPrefix) {
pattern = p.HandlerPrefix + pattern
}
mux.HandleFunc(pattern, handler)
}
// AttachHandlerToMux attaches the UI file handler to the mux.
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,119 @@
// Package zoraxy_plugin provides the Zoraxy plugin interface types and helpers.
// This is a vendored copy from github.com/tobychui/zoraxy/src/mod/plugins/zoraxy_plugin/
// Licensed under LGPL.
package zoraxy_plugin
import (
"encoding/json"
"fmt"
"os"
"strings"
)
type PluginType int
const (
PluginType_Router PluginType = 0
PluginType_Utilities PluginType = 1
)
type StaticCaptureRule struct {
CapturePath string `json:"capture_path"`
}
type ControlStatusCode int
const (
ControlStatusCode_CAPTURED ControlStatusCode = 280
ControlStatusCode_UNHANDLED ControlStatusCode = 284
ControlStatusCode_ERROR ControlStatusCode = 580
)
type SubscriptionEvent struct {
EventName string `json:"event_name"`
EventSource string `json:"event_source"`
Payload string `json:"payload"`
}
type RuntimeConstantValue struct {
ZoraxyVersion string `json:"zoraxy_version"`
ZoraxyUUID string `json:"zoraxy_uuid"`
DevelopmentBuild bool `json:"development_build"`
}
type PermittedAPIEndpoint struct {
Method string `json:"method"`
Endpoint string `json:"endpoint"`
Reason string `json:"reason"`
}
type IntroSpect struct {
ID string `json:"id"`
Name string `json:"name"`
Author string `json:"author"`
AuthorContact string `json:"author_contact"`
Description string `json:"description"`
URL string `json:"url"`
Type PluginType `json:"type"`
VersionMajor int `json:"version_major"`
VersionMinor int `json:"version_minor"`
VersionPatch int `json:"version_patch"`
StaticCapturePaths []StaticCaptureRule `json:"static_capture_paths"`
StaticCaptureIngress string `json:"static_capture_ingress"`
DynamicCaptureSniff string `json:"dynamic_capture_sniff"`
DynamicCaptureIngress string `json:"dynamic_capture_ingress"`
UIPath string `json:"ui_path"`
SubscriptionPath string `json:"subscription_path"`
SubscriptionsEvents map[string]string `json:"subscriptions_events"`
PermittedAPIEndpoints []PermittedAPIEndpoint `json:"permitted_api_endpoints"`
}
type ConfigureSpec struct {
Port int `json:"port"`
RuntimeConst RuntimeConstantValue `json:"runtime_const"`
APIKey string `json:"api_key,omitempty"`
ZoraxyPort int `json:"zoraxy_port,omitempty"`
}
// ServeIntroSpect checks for -introspect flag and prints the spec as JSON, then exits.
func ServeIntroSpect(pluginSpect *IntroSpect) {
if len(os.Args) > 1 && os.Args[1] == "-introspect" {
jsonData, _ := json.MarshalIndent(pluginSpect, "", " ")
fmt.Println(string(jsonData))
os.Exit(0)
}
}
// RecvConfigureSpec reads the -configure flag from command line args.
func RecvConfigureSpec() (*ConfigureSpec, error) {
for i, arg := range os.Args {
if strings.HasPrefix(arg, "-configure=") {
var spec ConfigureSpec
if err := json.Unmarshal([]byte(arg[11:]), &spec); err != nil {
return nil, err
}
return &spec, nil
} else if arg == "-configure" {
var spec ConfigureSpec
if len(os.Args) > i+1 {
if err := json.Unmarshal([]byte(os.Args[i+1]), &spec); err != nil {
return nil, err
}
return &spec, nil
}
return nil, fmt.Errorf("no argument after -configure flag")
}
}
return nil, fmt.Errorf("-configure flag not found")
}
// ServeAndRecvSpec serves introspection and returns config in one call.
func ServeAndRecvSpec(pluginSpect *IntroSpect) (*ConfigureSpec, error) {
ServeIntroSpect(pluginSpect)
return RecvConfigureSpec()
}