commit e8a3ed37427e8b4122ab245f3dd9de824503ae5b Author: cclohmar Date: Wed Oct 14 13:51:59 2026 +0000 feat: initial commit of Header Tagger plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e0bec83 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..f311994 --- /dev/null +++ b/README.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4aaeb9b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module zoraxy-headers + +go 1.21 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..8a05ad7 --- /dev/null +++ b/install.sh @@ -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 "" diff --git a/main.go b/main.go new file mode 100644 index 0000000..6699800 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/models.go b/models.go new file mode 100644 index 0000000..1164612 --- /dev/null +++ b/models.go @@ -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"` +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..bdf21dd --- /dev/null +++ b/server.go @@ -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}) +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..f810bda --- /dev/null +++ b/web/app.js @@ -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, '&').replace(//g, '>'); +} + +function escAttr(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, '''); +} + +function renderGroups(data) { + var container = document.getElementById('groups'); + var groups = data.groups || []; + + if (groups.length === 0) { + container.innerHTML = '
No tagged domains found. Tag a domain in Zoraxy (e.g. "tracking") and refresh.
'; + return; + } + + var html = groups.map(function (g) { + var domainsHtml = g.domains.map(function (d) { + var isSource = (d === g.source); + if (isSource) { + return '
  • ' + esc(d) + ' SOURCE
  • '; + } + return '
  • '; + }).join(''); + + var noSource = !g.hasSource + ? '
    No domain in this tag has headers yet. Set headers on one domain (via Zoraxy UI) to use it as the source.
    ' + : ''; + + return ( + '
    ' + + '

    ' + esc(g.tag) + '

    ' + + noSource + + '' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '
    ' + ); + }).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 = '' + ok + ' applied' + (fail ? ', ' + fail + ' failed' : ''); + 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 = '' + esc(msg) + ''; + } + }); +} + +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 = '' + ok + ' removed' + (fail ? ', ' + fail + ' failed' : ''); + 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 = '' + esc(msg) + ''; + } + }); +} + +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(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..614d779 --- /dev/null +++ b/web/index.html @@ -0,0 +1,28 @@ + + + + + + + Header Tagger + + + +
    +

    Header Tagger

    +

    Copy custom headers from a source domain to every other domain sharing the same tag.

    +
    + +
    +
    +
    Loading tags...
    +
    +
    + + + + + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..7c264ee --- /dev/null +++ b/web/style.css @@ -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; } diff --git a/zoraxy_plugin/embed_webserver.go b/zoraxy_plugin/embed_webserver.go new file mode 100644 index 0000000..bb2b08a --- /dev/null +++ b/zoraxy_plugin/embed_webserver.go @@ -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()) +} diff --git a/zoraxy_plugin/zoraxy_plugin.go b/zoraxy_plugin/zoraxy_plugin.go new file mode 100644 index 0000000..a60dbab --- /dev/null +++ b/zoraxy_plugin/zoraxy_plugin.go @@ -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() +}