simplify: transparent proxy — plugin just forwards to WAF, copies response. Remove backend routing, return port listener, hosts.go
This commit is contained in:
parent
d2fd6edd3a
commit
f40971b5bb
7 changed files with 27 additions and 235 deletions
|
|
@ -8,12 +8,9 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all Firewall plugin settings.
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
WAFURL string `json:"waf_url"`
|
WAFURL string `json:"waf_url"`
|
||||||
BackendURL string `json:"backend_url"` // where to proxy allowed traffic
|
|
||||||
ReturnPort int `json:"return_port"`
|
|
||||||
HealthInterval int `json:"health_interval"`
|
HealthInterval int `json:"health_interval"`
|
||||||
TimeoutMs int `json:"timeout_ms"`
|
TimeoutMs int `json:"timeout_ms"`
|
||||||
}
|
}
|
||||||
|
|
@ -35,8 +32,6 @@ func defaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
WAFURL: "http://127.0.0.1:8080",
|
WAFURL: "http://127.0.0.1:8080",
|
||||||
BackendURL: "http://127.0.0.1:80",
|
|
||||||
ReturnPort: 8080,
|
|
||||||
HealthInterval: 5,
|
HealthInterval: 5,
|
||||||
TimeoutMs: 500,
|
TimeoutMs: 500,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
hosts.go
76
hosts.go
|
|
@ -1,76 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// proxyRule is the minimal structure we need from Zoraxy's proxy config files.
|
|
||||||
type proxyRule struct {
|
|
||||||
Domain string `json:"RootOrMatchingDomain"`
|
|
||||||
ActiveOrigins []struct {
|
|
||||||
Origin string `json:"OriginIpOrDomain"`
|
|
||||||
} `json:"ActiveOrigins"`
|
|
||||||
Disabled bool `json:"Disabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostMap maps request Host headers to backend URLs.
|
|
||||||
var (
|
|
||||||
hostMap = make(map[string]string)
|
|
||||||
hostMapMu sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
// loadHostMap reads Zoraxy proxy configs and builds a host→backend map.
|
|
||||||
func loadHostMap() {
|
|
||||||
hostMapMu.Lock()
|
|
||||||
defer hostMapMu.Unlock()
|
|
||||||
|
|
||||||
hostMap = make(map[string]string)
|
|
||||||
proxyDir := "/opt/zoraxy/conf/proxy"
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(proxyDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("WARN: cannot read proxy configs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !strings.HasSuffix(entry.Name(), ".config") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := filepath.Join(proxyDir, entry.Name())
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var rule proxyRule
|
|
||||||
if err := json.Unmarshal(data, &rule); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if rule.Disabled || len(rule.ActiveOrigins) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hostMap[strings.ToLower(rule.Domain)] = rule.ActiveOrigins[0].Origin
|
|
||||||
}
|
|
||||||
log.Printf("Firewall: loaded %d proxy backends", len(hostMap))
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveBackend finds the backend URL for a given Host header.
|
|
||||||
// Falls back to the configured BackendURL if no match.
|
|
||||||
func resolveBackend(host string) string {
|
|
||||||
hostMapMu.RLock()
|
|
||||||
defer hostMapMu.RUnlock()
|
|
||||||
|
|
||||||
if backend, ok := hostMap[strings.ToLower(host)]; ok {
|
|
||||||
return backend
|
|
||||||
}
|
|
||||||
if backend, ok := hostMap[strings.ToLower(strings.Split(host, ":")[0])]; ok {
|
|
||||||
return backend
|
|
||||||
}
|
|
||||||
// Fallback
|
|
||||||
return getConfig().BackendURL
|
|
||||||
}
|
|
||||||
51
main.go
51
main.go
|
|
@ -15,14 +15,13 @@ var webFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
loadConfig()
|
loadConfig()
|
||||||
loadHostMap() // build domain→backend mapping from Zoraxy proxy rules
|
|
||||||
|
|
||||||
spec := &zoraxy_plugin.IntroSpect{
|
spec := &zoraxy_plugin.IntroSpect{
|
||||||
ID: "zoraxy-firewall",
|
ID: "zoraxy-firewall",
|
||||||
Name: "Firewall",
|
Name: "Firewall",
|
||||||
Author: "Zoraxy Community",
|
Author: "Zoraxy Community",
|
||||||
AuthorContact: "",
|
AuthorContact: "",
|
||||||
Description: "Inline WAF proxy with health checks and fail-open bypass.",
|
Description: "Transparent WAF proxy — forwards traffic to firewall for inline inspection.",
|
||||||
URL: "",
|
URL: "",
|
||||||
Type: zoraxy_plugin.PluginType_Router,
|
Type: zoraxy_plugin.PluginType_Router,
|
||||||
VersionMajor: 1,
|
VersionMajor: 1,
|
||||||
|
|
@ -39,33 +38,13 @@ func main() {
|
||||||
log.Fatalf("failed to receive config: %v", err)
|
log.Fatalf("failed to receive config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start background health checker.
|
|
||||||
startHealthCheck()
|
startHealthCheck()
|
||||||
|
|
||||||
// Return port listener for WAF async callbacks.
|
|
||||||
cfg := getConfig()
|
|
||||||
if cfg.ReturnPort > 0 {
|
|
||||||
go func() {
|
|
||||||
rmux := http.NewServeMux()
|
|
||||||
rmux.HandleFunc("/verdict", handleReturnVerdict)
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.ReturnPort)
|
|
||||||
log.Printf("Firewall: return listener on %s", addr)
|
|
||||||
if err := http.ListenAndServe(addr, rmux); err != nil {
|
|
||||||
log.Printf("Firewall: return listener error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Static capture handler — must match sub-paths (Zoraxy sends /inspect/some/page).
|
|
||||||
mux.Handle("/inspect/", http.HandlerFunc(handleInspect))
|
mux.Handle("/inspect/", http.HandlerFunc(handleInspect))
|
||||||
mux.HandleFunc("/inspect", handleInspect)
|
mux.HandleFunc("/inspect", handleInspect)
|
||||||
|
|
||||||
// UI + config API.
|
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(spec.ID, &webFS, "web", spec.UIPath)
|
||||||
uiRouter := zoraxy_plugin.NewPluginEmbedUIRouter(
|
|
||||||
spec.ID, &webFS, "web", spec.UIPath,
|
|
||||||
)
|
|
||||||
uiRouter.HandleFunc("/api/config", handleGetConfig, mux)
|
uiRouter.HandleFunc("/api/config", handleGetConfig, mux)
|
||||||
uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux)
|
uiRouter.HandleFunc("/api/config/save", handleSaveConfig, mux)
|
||||||
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
|
uiRouter.HandleFunc("/api/stats", handleGetStats, mux)
|
||||||
|
|
@ -76,16 +55,13 @@ func main() {
|
||||||
uiRouter.AttachHandlerToMux(mux)
|
uiRouter.AttachHandlerToMux(mux)
|
||||||
|
|
||||||
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
|
addr := fmt.Sprintf("127.0.0.1:%d", config.Port)
|
||||||
log.Printf("firewall v%d.%d.%d listening on %s",
|
log.Printf("firewall v%d.%d.%d listening on %s", spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
|
||||||
spec.VersionMajor, spec.VersionMinor, spec.VersionPatch, addr)
|
|
||||||
|
|
||||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config API ---
|
|
||||||
|
|
||||||
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
func handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, getConfig())
|
writeJSON(w, http.StatusOK, getConfig())
|
||||||
}
|
}
|
||||||
|
|
@ -101,12 +77,6 @@ func handleSaveConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if u := r.FormValue("waf_url"); u != "" {
|
if u := r.FormValue("waf_url"); u != "" {
|
||||||
cfg.WAFURL = u
|
cfg.WAFURL = u
|
||||||
}
|
}
|
||||||
if u := r.FormValue("back_url"); u != "" {
|
|
||||||
cfg.BackendURL = u
|
|
||||||
}
|
|
||||||
if v := r.FormValue("return_port"); v != "" {
|
|
||||||
fmt.Sscanf(v, "%d", &cfg.ReturnPort)
|
|
||||||
}
|
|
||||||
if v := r.FormValue("health_interval"); v != "" {
|
if v := r.FormValue("health_interval"); v != "" {
|
||||||
fmt.Sscanf(v, "%d", &cfg.HealthInterval)
|
fmt.Sscanf(v, "%d", &cfg.HealthInterval)
|
||||||
}
|
}
|
||||||
|
|
@ -127,21 +97,6 @@ func handleGetStats(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, metricsSnapshot())
|
writeJSON(w, http.StatusOK, metricsSnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleReturnVerdict(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var v struct {
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
|
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad payload"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("Firewall: async verdict %s (reason=%s)", v.Verdict, v.Reason)
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -43,62 +41,47 @@ func handleInspect(w http.ResponseWriter, r *http.Request) {
|
||||||
atomic.AddUint64(&requestsTotal, 1)
|
atomic.AddUint64(&requestsTotal, 1)
|
||||||
cfg := getConfig()
|
cfg := getConfig()
|
||||||
|
|
||||||
if !cfg.Enabled {
|
if !cfg.Enabled || breaker.State() == StateOpen {
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
proxyToBackend(w, r)
|
if breaker.State() == StateOpen {
|
||||||
|
log.Printf("Firewall: breaker OPEN — bypass %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
// Bypass: forward to WAF (WAF sends back to Zoraxy for routing).
|
||||||
|
forwardToWAF(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if breaker.State() == StateOpen {
|
resp, err := forwardToWAF(w, r)
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
|
||||||
log.Printf("Firewall: breaker OPEN — fail-open %s %s", r.Method, r.URL.Path)
|
|
||||||
proxyToBackend(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := inspect(r)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
atomic.AddUint64(&requestsBypassed, 1)
|
atomic.AddUint64(&requestsBypassed, 1)
|
||||||
log.Printf("Firewall: inspection error — fail-open: %v", err)
|
|
||||||
breaker.RecordFailure()
|
breaker.RecordFailure()
|
||||||
proxyToBackend(w, r)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == 403 {
|
||||||
|
breaker.RecordSuccess()
|
||||||
|
atomic.AddUint64(&requestsBlocked, 1)
|
||||||
|
log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
breaker.RecordSuccess()
|
breaker.RecordSuccess()
|
||||||
|
|
||||||
if status == 403 {
|
|
||||||
atomic.AddUint64(&requestsBlocked, 1)
|
|
||||||
log.Printf("Firewall: BLOCKED %s %s", r.Method, r.URL.Path)
|
|
||||||
w.WriteHeader(403)
|
|
||||||
w.Write([]byte("<html><body><h1>403 Forbidden</h1><p>Blocked by Firewall</p></body></html>"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
atomic.AddUint64(&requestsAllowed, 1)
|
atomic.AddUint64(&requestsAllowed, 1)
|
||||||
proxyToBackend(w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func proxyToBackend(w http.ResponseWriter, r *http.Request) {
|
// forwardToWAF proxies the request to the WAF and copies the response back.
|
||||||
backend := resolveBackend(r.Host)
|
func forwardToWAF(w http.ResponseWriter, r *http.Request) (*http.Response, error) {
|
||||||
if !strings.HasPrefix(backend, "http") {
|
cfg := getConfig()
|
||||||
backend = "http://" + backend
|
|
||||||
}
|
|
||||||
target, err := url.Parse(backend)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid backend URL", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
originalPath := r.Header.Get("X-Zoraxy-Uri")
|
originalPath := r.Header.Get("X-Zoraxy-Uri")
|
||||||
if originalPath == "" {
|
if originalPath == "" {
|
||||||
originalPath = r.URL.Path
|
originalPath = r.URL.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, target.String()+originalPath, r.Body)
|
proxyReq, err := http.NewRequestWithContext(r.Context(), r.Method, cfg.WAFURL+originalPath, r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "proxy error", http.StatusInternalServerError)
|
http.Error(w, "proxy error", http.StatusInternalServerError)
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, vals := range r.Header {
|
for key, vals := range r.Header {
|
||||||
|
|
@ -106,13 +89,12 @@ func proxyToBackend(w http.ResponseWriter, r *http.Request) {
|
||||||
proxyReq.Header.Add(key, v)
|
proxyReq.Header.Add(key, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proxyReq.Header.Set("X-Forwarded-For", r.RemoteAddr)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(getConfig().TimeoutMs) * time.Millisecond}
|
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
|
||||||
resp, err := client.Do(proxyReq)
|
resp, err := client.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "backend unreachable", http.StatusBadGateway)
|
http.Error(w, "WAF unreachable", http.StatusBadGateway)
|
||||||
return
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
|
@ -123,4 +105,6 @@ func proxyToBackend(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
w.WriteHeader(resp.StatusCode)
|
w.WriteHeader(resp.StatusCode)
|
||||||
io.Copy(w, resp.Body)
|
io.Copy(w, resp.Body)
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
proxy.go
58
proxy.go
|
|
@ -1,58 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxBodySample = 4096
|
|
||||||
|
|
||||||
// inspect sends the request metadata + body sample to the WAF for inspection.
|
|
||||||
// Returns the WAF's HTTP status code or an error if unreachable.
|
|
||||||
func inspect(r *http.Request) (int, error) {
|
|
||||||
cfg := getConfig()
|
|
||||||
|
|
||||||
// Sample body.
|
|
||||||
var bodySample []byte
|
|
||||||
if r.Body != nil {
|
|
||||||
bodySample = make([]byte, maxBodySample)
|
|
||||||
n, _ := io.ReadFull(r.Body, bodySample)
|
|
||||||
bodySample = bodySample[:n]
|
|
||||||
// Restore body so downstream can read it.
|
|
||||||
r.Body = io.NopCloser(io.MultiReader(bytes.NewReader(bodySample), r.Body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build inspection payload with the original URI.
|
|
||||||
originalPath := r.Header.Get("X-Zoraxy-Uri")
|
|
||||||
if originalPath == "" {
|
|
||||||
originalPath = r.URL.String()
|
|
||||||
}
|
|
||||||
payload, err := json.Marshal(map[string]interface{}{
|
|
||||||
"method": r.Method,
|
|
||||||
"url": originalPath,
|
|
||||||
"host": r.Host,
|
|
||||||
"remote_addr": r.RemoteAddr,
|
|
||||||
"content_type": r.Header.Get("Content-Type"),
|
|
||||||
"content_length": r.ContentLength,
|
|
||||||
"headers": r.Header,
|
|
||||||
"body_sample": bodySample,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("marshal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond}
|
|
||||||
resp, err := client.Post(cfg.WAFURL+"/inspect", "application/json", bytes.NewReader(payload))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("unreachable: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
log.Printf("Firewall: %s %s → status %d", r.Method, r.URL.Path, resp.StatusCode)
|
|
||||||
return resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ function loadConfig() {
|
||||||
$('#fw-enabled').prop('checked', data.enabled);
|
$('#fw-enabled').prop('checked', data.enabled);
|
||||||
$('#enabled-label').text(data.enabled ? 'Firewall Enabled' : 'Firewall Disabled');
|
$('#enabled-label').text(data.enabled ? 'Firewall Enabled' : 'Firewall Disabled');
|
||||||
$('#waf-url').val(data.waf_url);
|
$('#waf-url').val(data.waf_url);
|
||||||
$('#back-url').val(data.backend_url);
|
|
||||||
$('#return-port').val(data.return_port);
|
$('#return-port').val(data.return_port);
|
||||||
$('#health-interval').val(data.health_interval);
|
$('#health-interval').val(data.health_interval);
|
||||||
$('#timeout-ms').val(data.timeout_ms);
|
$('#timeout-ms').val(data.timeout_ms);
|
||||||
|
|
@ -33,7 +32,6 @@ function saveConfig() {
|
||||||
data: {
|
data: {
|
||||||
enabled: $('#fw-enabled').is(':checked'),
|
enabled: $('#fw-enabled').is(':checked'),
|
||||||
waf_url: $('#waf-url').val().trim(),
|
waf_url: $('#waf-url').val().trim(),
|
||||||
back_url: $('#back-url').val().trim(),
|
|
||||||
return_port: $('#return-port').val(),
|
return_port: $('#return-port').val(),
|
||||||
health_interval: $('#health-interval').val(),
|
health_interval: $('#health-interval').val(),
|
||||||
timeout_ms: $('#timeout-ms').val()
|
timeout_ms: $('#timeout-ms').val()
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,6 @@
|
||||||
<small>The firewall application URL (e.g. Wallarm, ModSecurity, Coraza).</small>
|
<small>The firewall application URL (e.g. Wallarm, ModSecurity, Coraza).</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="back-url">Backend URL (allowed traffic)</label>
|
|
||||||
<input type="text" id="back-url" placeholder="http://10.1.1.10:8001">
|
|
||||||
<small>Traffic is proxied here when the WAF allows it.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="return-port">Return Port</label>
|
<label for="return-port">Return Port</label>
|
||||||
<input type="number" id="return-port" placeholder="8080" min="1" max="65535">
|
<input type="number" id="return-port" placeholder="8080" min="1" max="65535">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue