Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ Each `listener` block defines a single proxy listener:
| `upstream_ca_file` | Listener‑specific CA bundle for upstream HTTPS. |
| `upstream_skip_tls_verify` | Skip verification of upstream certificates for this listener. |

### Upstream Base Path Mapping

If a listener `upstream` includes a non-empty path (for example, `https://api.example.com/v1`) or a query string, Finch maps incoming paths and merges queries as follows when no route rule overrides the upstream:

- `/` → `/v1` (no trailing slash added)
- `/<p>` → `/v1/<p>`
- Upstream query params merge with client query params (order-insensitive).

This path joining and query merging matches the route rule path semantics and preserves proper URL escaping.

If the upstream path is exactly `/`, it is treated as having no base path (no changes to incoming request paths).

## Suricata and Galah Blocks

If `suricata.enabled = true` and a `rules_dir` is provided, Finch watches the directory for `.rules` files and reloads them automatically. Only supported Suricata keywords will be applied.
Expand Down
258 changes: 258 additions & 0 deletions internal/proxy/default_upstream_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package proxy

import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/0x4D31/finch/internal/logger"
"github.com/0x4D31/finch/internal/rules"
)

// These tests cover default-upstream base-path mapping semantics when no rule matches.
func TestDefaultUpstream_BasePathRoot(t *testing.T) {
var gotPath, gotQuery string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

u, _ := url.Parse(backend.URL)
u.Path = "/anything"

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", u.String(), "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/")
if err != nil { t.Fatalf("client get: %v", err) }
_ = resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotPath != "/anything" {
t.Fatalf("path want /anything got %s", gotPath)
}
if gotQuery != "" {
t.Fatalf("query want empty got %s", gotQuery)
}
}

func TestDefaultUpstream_BasePathSubpath(t *testing.T) {
var gotPath, gotQuery string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

u, _ := url.Parse(backend.URL)
u.Path = "/anything"

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", u.String(), "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/foo")
if err != nil { t.Fatalf("client get: %v", err) }
_ = resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotPath != "/anything/foo" {
t.Fatalf("path want /anything/foo got %s", gotPath)
}
if gotQuery != "" {
t.Fatalf("query want empty got %s", gotQuery)
}
}

func TestDefaultUpstream_HttpbinStyleFixedRoot(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := http.StatusNotFound
if r.URL.Path == "/anything" {
status = http.StatusOK
}
w.WriteHeader(status)
}))
defer backend.Close()

u, _ := url.Parse(backend.URL)
u.Path = "/anything"

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", u.String(), "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/")
if err != nil { t.Fatalf("client get: %v", err) }
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}

func TestDefaultUpstream_QueryMerge(t *testing.T) {
var gotPath, gotQuery string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery = r.URL.Path, r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

u, _ := url.Parse(backend.URL)
u.Path = "/anything"
u.RawQuery = "version=1"

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", u.String(), "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/foo?user=abc")
if err != nil { t.Fatalf("client get: %v", err) }
_ = resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}

if gotPath != "/anything/foo" {
t.Fatalf("path want /anything/foo got %s", gotPath)
}
// Order-insensitive; just check both keys exist
if gotQuery != "version=1&user=abc" && gotQuery != "user=abc&version=1" {
t.Fatalf("query want version=1&user=abc (any order), got %s", gotQuery)
}
}

func TestDefaultUpstream_NoBasePathNoop(t *testing.T) {
var gotPath string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", backend.URL, "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/bar")
if err != nil { t.Fatalf("client get: %v", err) }
_ = resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotPath != "/bar" {
t.Fatalf("path want /bar got %s", gotPath)
}
}

func TestDefaultUpstream_TrailingSlashPreserved(t *testing.T) {
var gotPath string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

u, _ := url.Parse(backend.URL)
u.Path = "/anything/"

lgr, err := logger.New(t.TempDir() + "/events.jsonl")
if err != nil { t.Fatalf("new logger: %v", err) }
defer lgr.Close()

eng := &rules.Engine{DefaultAction: rules.ActionAllow}

svr, err := New("test", "127.0.0.1:0", u.String(), "", "", lgr, eng, nil, nil, nil, nil, "", false)
if err != nil { t.Fatalf("new server: %v", err) }

ln, err := net.Listen("tcp", svr.ListenAddr)
if err != nil { t.Fatalf("listen: %v", err) }
defer ln.Close()
go func() { _ = svr.Serve(ln) }()
time.Sleep(50 * time.Millisecond)

client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
resp, err := client.Get("https://" + ln.Addr().String() + "/")
if err != nil { t.Fatalf("client get: %v", err) }
_ = resp.Body.Close()

if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}

if gotPath != "/anything/" {
t.Fatalf("path want /anything/ got %s", gotPath)
}
}
20 changes: 20 additions & 0 deletions internal/proxy/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,26 @@ func (h *ruleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}

// Map request path/query onto default upstream base path (no rule override).
// This aligns default upstream semantics with rule-based routing, including
// canonical joining and query merging handled by mapPath.
if matchedRule == nil && target != nil && ((target.Path != "" && target.Path != "/") || target.RawQuery != "") {
dest := target.RequestURI()
newPath, newRawPath, q := mapPath(
r.URL.Path,
r.URL.EscapedPath(),
r.URL.RawQuery,
dest,
nil, // no matched rule
"",
)
r.URL.Path = newPath
r.URL.RawPath = newRawPath
r.URL.RawQuery = q
// keep only scheme/host on the target; path/query already applied to r.URL
target = &url.URL{Scheme: target.Scheme, Host: target.Host}
}

// Prepare deception response early so it can be logged and reused.
var deceiveResp galahllm.JSONResponse
var deceiveErr error
Expand Down
66 changes: 41 additions & 25 deletions internal/proxy/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,18 @@ func singleSlashJoin(a, b string) string {
}
}

// mapPath rewrites reqPath based on upstreamPath and matched rule.
// If upstreamPath is empty, the original path is returned.
// If upstreamPath ends with a slash, the prefix or exact path of the matched
// rule is stripped from the request path and appended to upstreamPath.
// Otherwise upstreamPath replaces the request path completely.
// mapPath rewrites reqPath and reqRawPath based on upstreamPath and matched rule.
// It returns the rewritten path, raw path and query string.
// If upstreamPath is empty, the original values are returned.
// When upstreamPath contains a query, it is merged with the original query string,
// with upstream parameters taking precedence on key conflicts.
// Returns (newPath, newRawPath, mergedQuery).
// Semantics:
// - If upstreamPath is empty: return original values.
// - If upstreamPath has a query: merge it with reqQuery (upstream keys win).
// - If upstreamPath ends with '/': strip rule.StripPrefix or matchedPrefix from request,
// then append the remainder under upstreamPath.
// - Otherwise (no trailing slash):
// * If rule != nil: upstreamPath replaces the request path (legacy route semantics).
// * If rule == nil and upstreamPath is non-empty and not "/": treat it as a base path:
// - req "/" → upstreamPath
// - req "/x" → upstreamPath + "/x"
func mapPath(reqPath, reqRawPath, reqQuery, upstreamPath string, rule *rules.Rule, matchedPrefix string) (string, string, string) {
newPath := reqPath
newRawPath := reqRawPath
Expand All @@ -52,22 +54,36 @@ func mapPath(reqPath, reqRawPath, reqQuery, upstreamPath string, rule *rules.Rul
upQuery = up.RawQuery
}

if strings.HasSuffix(upPath, "/") {
restPath := newPath
restRawPath := newRawPath
if rule != nil && rule.StripPrefix != "" {
restPath = strings.TrimPrefix(newPath, rule.StripPrefix)
restRawPath = strings.TrimPrefix(newRawPath, rule.StripPrefix)
} else if matchedPrefix != "" {
restPath = strings.TrimPrefix(newPath, matchedPrefix)
restRawPath = strings.TrimPrefix(newRawPath, matchedPrefix)
}
newPath = singleSlashJoin(upPath, restPath)
newRawPath = singleSlashJoin(upPath, restRawPath)
} else {
newPath = upPath
newRawPath = upPath
}
if strings.HasSuffix(upPath, "/") {
restPath := newPath
restRawPath := newRawPath
if rule != nil && rule.StripPrefix != "" {
restPath = strings.TrimPrefix(newPath, rule.StripPrefix)
restRawPath = strings.TrimPrefix(newRawPath, rule.StripPrefix)
} else if matchedPrefix != "" {
restPath = strings.TrimPrefix(newPath, matchedPrefix)
restRawPath = strings.TrimPrefix(newRawPath, matchedPrefix)
}
newPath = singleSlashJoin(upPath, restPath)
newRawPath = singleSlashJoin(upPath, restRawPath)
} else {
// Default-upstream special case: treat a non-empty, non-"/" upstream path
// as a base path when no rule matched. Root ("/") maps to the base path
// itself; non-root requests are joined under the base path.
if rule == nil && upPath != "" && upPath != "/" {
if reqPath != "/" && reqPath != "" {
upWithSlash := upPath + "/"
newPath = singleSlashJoin(upWithSlash, newPath)
newRawPath = singleSlashJoin(upWithSlash, newRawPath)
} else {
newPath = upPath
newRawPath = upPath
}
} else {
newPath = upPath
newRawPath = upPath
}
}

if upQuery != "" {
reqVals, _ := url.ParseQuery(reqQuery)
Expand Down