From e8a3ed37427e8b4122ab245f3dd9de824503ae5b Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 14 Oct 2026 13:51:59 +0000 Subject: [PATCH] feat: initial commit of Header Tagger plugin --- .gitignore | 21 +++ README.md | 108 ++++++++++++ go.mod | 3 + install.sh | 67 ++++++++ main.go | 70 ++++++++ models.go | 52 ++++++ server.go | 284 +++++++++++++++++++++++++++++++ web/app.js | 143 ++++++++++++++++ web/index.html | 28 +++ web/style.css | 109 ++++++++++++ zoraxy_plugin/embed_webserver.go | 135 +++++++++++++++ zoraxy_plugin/zoraxy_plugin.go | 119 +++++++++++++ 12 files changed, 1139 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100755 install.sh create mode 100644 main.go create mode 100644 models.go create mode 100644 server.go create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css create mode 100644 zoraxy_plugin/embed_webserver.go create mode 100644 zoraxy_plugin/zoraxy_plugin.go 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() +}