From e83961c5977b36ef5de6d73049bff72ece26897c Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Thu, 17 Sep 2026 19:02:01 +0200 Subject: [PATCH] added proxy status engine Signed-off-by: RonniSkansing --- backend/proxy/proxy.go | 72 +++++++++++++++++++ backend/service/proxy.go | 2 +- backend/service/proxySessionManager.go | 1 + frontend/src/lib/utils/proxyYamlCompletion.js | 25 ++++++- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/backend/proxy/proxy.go b/backend/proxy/proxy.go index 3cd9bc19..26232fd1 100644 --- a/backend/proxy/proxy.go +++ b/backend/proxy/proxy.go @@ -808,6 +808,9 @@ func (m *ProxyHandler) captureResponseDataWithContext(resp *http.Response, reqCt // capture cookies, headers, and body m.onResponseCookies(resp, reqCtx.Session) m.onResponseHeader(resp, reqCtx.Session) + // status rules report session state and read no body, so they run for any + // response regardless of content type, unlike the body capture below + m.onResponseStatus(resp, reqCtx.Session) contentType := resp.Header.Get("Content-Type") if m.shouldProcessContent(contentType) { @@ -1511,6 +1514,10 @@ func (m *ProxyHandler) initializeRequiredCaptures(session *service.ProxySession) return true } for _, capture := range hCfg.Capture { + // status rules are operator diagnostics, never a capture the flow waits on + if capture.Engine == "status" { + continue + } if capture.Required == nil || *capture.Required { session.RequiredCaptures.Store(capture.Name, false) } @@ -1595,6 +1602,61 @@ func (m *ProxyHandler) onResponseBody(resp *http.Response, body []byte, session } } +// onResponseStatus evaluates status rules against a response. It runs for every +// response regardless of content type because it reads only session state, never +// the body. When a status rule path matches it reports the outstanding required +// captures. It never captures data or advances the flow. +func (m *ProxyHandler) onResponseStatus(resp *http.Response, session *service.ProxySession) { + hostConfig, exists := m.getHostConfig(session, resp.Request.Host) + if !exists { + return + } + for _, capture := range hostConfig.Capture { + if capture.Engine != "status" { + continue + } + methodMatches := capture.Method == "" || capture.Method == resp.Request.Method + if methodMatches && m.matchesPath(capture, resp.Request) { + m.reportCaptureStatus(capture, session, resp.Request) + } + } +} + +// reportCaptureStatus emits an operator info event listing the required capture +// rules that have not fired yet for this session. It is diagnostic only: it never +// stores captured data, marks completion, submits a cookie bundle or triggers a +// redirect, so it cannot advance or complete the campaign flow. It fires at most +// once per status rule per session. +func (m *ProxyHandler) reportCaptureStatus(capture service.ProxyServiceCaptureRule, session *service.ProxySession, req *http.Request) { + if session.CampaignRecipientID == nil || session.CampaignID == nil { + return + } + // fire once per status rule per session so page reloads do not repeat the event + if _, already := session.StatusReported.LoadOrStore(capture.Name, true); already { + return + } + + outstanding := []string{} + session.RequiredCaptures.Range(func(key, value interface{}) bool { + fired, _ := value.(bool) + name, ok := key.(string) + if ok && !fired { + outstanding = append(outstanding, name) + } + return true + }) + sort.Strings(outstanding) + + eventData := map[string]interface{}{ + capture.Name: map[string]interface{}{ + "capture_type": "status", + "outstanding": outstanding, + "complete": len(outstanding) == 0, + }, + } + m.createCampaignInfoEvent(session, eventData, req, session.UserAgent) +} + func (m *ProxyHandler) onResponseCookies(resp *http.Response, session *service.ProxySession) { hostConfig, exists := m.getHostConfig(session, resp.Request.Host) if !exists { @@ -1663,6 +1725,11 @@ func (m *ProxyHandler) shouldApplyCaptureRule(capture service.ProxyServiceCaptur return false } + // status is a diagnostic rule, it captures nothing and is handled in onResponseBody + if capture.Engine == "status" { + return false + } + // check capture source if capture.From != "" && capture.From != captureType && capture.From != "any" { return false @@ -1683,6 +1750,11 @@ func (m *ProxyHandler) shouldProcessResponseBodyCapture(capture service.ProxySer return false } + // status rules are handled by their own branch in onResponseBody + if capture.Engine == "status" { + return false + } + // handle path-based captures if capture.Path != "" && (capture.Method == "" || capture.Method == req.Method) { return m.matchesPath(capture, req) diff --git a/backend/service/proxy.go b/backend/service/proxy.go index 4719d5dd..2a287f4a 100644 --- a/backend/service/proxy.go +++ b/backend/service/proxy.go @@ -1022,7 +1022,7 @@ func (m *Proxy) validateCaptureRules(captureRules []ProxyServiceCaptureRule) err // validate engine field if capture.Engine != "" { - validEngines := []string{"regex", "header", "cookie", "json", "form", "urlencoded", "formdata", "multipart"} + validEngines := []string{"regex", "header", "cookie", "json", "form", "urlencoded", "formdata", "multipart", "status"} valid := false for _, validEngine := range validEngines { if capture.Engine == validEngine { diff --git a/backend/service/proxySessionManager.go b/backend/service/proxySessionManager.go index 8855d8ec..171dc2f7 100644 --- a/backend/service/proxySessionManager.go +++ b/backend/service/proxySessionManager.go @@ -24,6 +24,7 @@ type ProxySession struct { CreatedAt time.Time RequiredCaptures sync.Map // map[string]bool CapturedData sync.Map // map[string]map[string]string + StatusReported sync.Map // map[string]bool - status rule names already reported NextPageType atomic.Value // string - accessed concurrently by multiple requests IsComplete atomic.Bool // accessed concurrently when checking capture completion CookieBundleSubmitted atomic.Bool // accessed concurrently to prevent duplicate submissions diff --git a/frontend/src/lib/utils/proxyYamlCompletion.js b/frontend/src/lib/utils/proxyYamlCompletion.js index 8ec51687..ab870381 100644 --- a/frontend/src/lib/utils/proxyYamlCompletion.js +++ b/frontend/src/lib/utils/proxyYamlCompletion.js @@ -564,7 +564,7 @@ export class ProxyYamlCompletionProvider { kind: this.monaco.languages.CompletionItemKind.Property, insertText: 'engine: "regex"', documentation: - 'Capture engine type: regex, header, cookie, json, form, urlencoded, formdata, multipart', + 'Capture engine type: regex, header, cookie, json, form, urlencoded, formdata, multipart, status', range }, { @@ -736,6 +736,14 @@ export class ProxyYamlCompletionProvider { 'name: "capture_info"\n method: "GET"\n path: "/path"\n engine: "header"\n find: "x-request-id"\n event: "info"', documentation: 'Capture and save as info event (does not count as submitted data)', range + }, + { + label: 'capture status (diagnostic)', + kind: this.monaco.languages.CompletionItemKind.Snippet, + insertText: 'name: "checkpoint"\n path: "/path"\n engine: "status"', + documentation: + 'Diagnostic rule: report which required captures have not fired yet when the visitor reaches this path', + range } ]; } @@ -1081,7 +1089,20 @@ export class ProxyYamlCompletionProvider { } ]; - return isRewrite ? [...captureEngines, ...rewriteOnlyEngines] : captureEngines; + const captureOnlyEngines = [ + { + label: 'status', + kind: this.monaco.languages.CompletionItemKind.Value, + insertText: 'status', + documentation: + 'Diagnostic only — on visiting the rule path, report which required captures have not fired yet. Captures no data and does not affect the flow', + range + } + ]; + + return isRewrite + ? [...captureEngines, ...rewriteOnlyEngines] + : [...captureEngines, ...captureOnlyEngines]; } getEventSuggestions(range) {