feat: initial commit of Header Tagger plugin
This commit is contained in:
commit
e8a3ed3742
12 changed files with 1139 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
108
README.md
Normal 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
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module zoraxy-headers
|
||||||
|
|
||||||
|
go 1.21
|
||||||
67
install.sh
Executable file
67
install.sh
Executable 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
70
main.go
Normal 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
52
models.go
Normal 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
284
server.go
Normal 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
143
web/app.js
Normal 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, '&').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 = '<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
28
web/index.html
Normal 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
109
web/style.css
Normal 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; }
|
||||||
135
zoraxy_plugin/embed_webserver.go
Normal file
135
zoraxy_plugin/embed_webserver.go
Normal 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())
|
||||||
|
}
|
||||||
119
zoraxy_plugin/zoraxy_plugin.go
Normal file
119
zoraxy_plugin/zoraxy_plugin.go
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue