Compare commits

...

15 Commits

Author SHA1 Message Date
yusing
59238adb5b fix(middleware): skip body rewriters when buffering fails
Prevent response modifiers that require body rewriting from running when
the body rewrite gate blocks buffering (for example, chunked transfer
encoding).

Add an explicit `requiresBodyRewrite` capability and implement it for
HTML/theme/error-page modifiers, including bypass delegation.

Also add a regression test to ensure the original response body remains
readable and is not closed prematurely when rewrite is blocked.

This commit fixeds the "http: read on closed response body" with empty page error
happens when body-rewriting middleware (like themed) runs on responses where body rewrite is blocked (e.g. chunked),
then the gate restores an already-closed original body.
2026-03-01 03:40:43 +08:00
yusing
5f48f141ca fix(systeminfo): Collect all partitions then filter partitions
Keep only real disk-like mounts (/, /dev/*,
and /mnt/* excluding /mnt/ itself and /mnt/wsl) to avoid noisy or irrelevant entries in
disk metrics.

Normalize disk map keys to use mountpoints for empty/none and /dev/root
devices so usage data remains stable and accessible across environments.
2026-02-28 18:16:04 +08:00
yusing
a0adc51269 feat(rules): support multiline or |
treat lines ending with unquoted `|` or `&` as continued
conditions in `do` block headers so nested blocks parse correctly
across line breaks.

update `on` condition splitting to avoid breaking on newlines that
follow an unescaped trailing pipe, while still respecting quotes,
escapes, and bracket nesting.

add coverage for multiline `|`/`&` continuations in `do` parsing,
`splitAnd`, `parseOn`, and HTTP flow nested block behavior.
2026-02-28 18:16:04 +08:00
yusing
c002055892 chore(rules): update example to use new block syntax 2026-02-28 18:16:03 +08:00
yusing
d5406fb039 doc(entrypoint): escape [] in mermaid 2026-02-28 18:16:03 +08:00
Jarek Krochmalski
1bd8b5a696 fix(middleware): restore SSE streaming for POST endpoints (regression in v0.27.0) (#206)
* fix(middleware): restore SSE streaming for POST endpoints

Regression introduced in 16935865 (v0.27.0).

Before that commit, LazyResponseModifier only buffered HTML responses and
let everything else pass through via the IsBuffered() early return. The
refactor replaced it with NewResponseModifier which unconditionally buffers
all writes until FlushRelease() fires after the handler returns. That kills
real-time streaming for any SSE endpoint that uses POST.

The existing bypass at ServeHTTP line 193 only fires when the *request*
carries Accept: text/event-stream. That works for browser EventSource (which
always sets that header) but not for programmatic fetch() calls, which set
Content-Type: application/json on the request and only emit
Content-Type: text/event-stream on the *response*.

Fix: introduce ssePassthroughWriter, a thin http.ResponseWriter wrapper that
sits in front of the ResponseModifier. It watches for Content-Type:
text/event-stream in the response headers at the moment WriteHeader or the
first Write is called. Once detected it copies the buffered headers to the
real writer and switches all subsequent writes to pass directly through with
an immediate Flush(), bypassing the ResponseModifier buffer entirely.

Also tighten the Accept header check from == to strings.Contains so that
Accept: text/event-stream, */* is handled correctly.

Reported against Dockhand (https://github.com/Finsys/dockhand) where
container update progress, image pull logs and vulnerability scan output all
stopped streaming after users upgraded to GoDoxy v0.27.0. GET SSE endpoints
(container logs) continued to work because browsers send Accept:
text/event-stream for EventSource connections.

* fix(middleware): make Content-Type SSE check case-insensitive

* refactor(middleware): extract Content-Type into a named constant

* fix(middleware): enhance safe guard to avoid buffering SSE, WS and large bodies

Reverts some changes in 16935865 and apply more rubust handling.

Use a lazy response modifier that buffers only when the response is safe
to mutate. This prevents middleware from intercepting websocket/SSE
streams, encoded payloads, and non-text or oversized responses.

Set a 4MB max buffered size and gate buffering via response headers
(content type, transfer/content encoding, and content length). Skip
mutation when a response is not buffered or mutation setup fails, and
simplify chained response modifiers to operate on the same response.

Also update the goutils submodule for max body limit support.

---------

Co-authored-by: yusing <yusing.wys@gmail.com>
2026-02-28 17:15:41 +08:00
yusing
79327e98bd chore: update submodule goutils 2026-02-26 14:19:03 +08:00
yusing
206f69d249 chore(scripts): fix docker depedencies in refresh-compat.sh 2026-02-26 01:14:04 +08:00
yusing
3f6b09d05e chore(scripts): narrow git diff to only include go files in refresh-compat.sh 2026-02-26 01:11:41 +08:00
yusing
af68eb4b18 build: create placeholder JS files and tidy Go modules
- Add script logic to create empty placeholder files for minified JS files
  so go vet won't complain about missing files
- Run go mod tidy in root and agent directory to clean up dependencies
2026-02-26 01:03:02 +08:00
yusing
9927267149 chore: improve refresh-compat.sh with error handling and fix sed application
Add `set -euo pipefail` for strict error handling, check for clean working tree before running, and add trap for cleanup. Move sed replacements from patch file to actual changed Go files to correctly apply sonic-to-json transformations after checkout.
2026-02-26 00:46:12 +08:00
yusing
af8cddc1b2 chore: add AGENTS.md 2026-02-26 00:39:10 +08:00
yusing
c74da5cba9 build: make POST_BUILD always run 2026-02-26 00:35:44 +08:00
yusing
c23cf8ef06 ci: refactor compat branch refresh to use patch-based approach 2026-02-26 00:34:48 +08:00
yusing
733716ba2b build(cli): fix build path and unify build command
Use the shared build target for CLI binaries and upload
artifacts to GitHub releases on tag builds.
2026-02-25 14:40:06 +08:00
22 changed files with 720 additions and 201 deletions

View File

@@ -47,14 +47,20 @@ jobs:
- name: Build CLI
run: |
make CLI_BIN_PATH=bin/${{ matrix.binary_name }} build-cli
make cli=1 NAME=${{ matrix.binary_name }} build
- name: Check binary
run: |
file bin/${{ matrix.binary_name }}
- name: Upload artifact
- name: Upload
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.binary_name }}
path: bin/${{ matrix.binary_name }}
- name: Upload to release
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: bin/${{ matrix.binary_name }}

View File

@@ -1,4 +1,4 @@
name: Cherry-pick into Compat
name: Refresh Compat from Main Patch
on:
push:
@@ -8,7 +8,7 @@ on:
- ".github/workflows/merge-main-into-compat.yml"
jobs:
cherry-pick:
refresh-compat:
runs-on: ubuntu-latest
permissions:
contents: write
@@ -20,20 +20,9 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Cherry-pick commits from last tag
- name: Refresh compat with single patch commit
run: |
git fetch origin compat
git checkout compat
CURRENT_TAG=${{ github.ref_name }}
PREV_TAG=$(git describe --tags --abbrev=0 $CURRENT_TAG^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found. Cherry-picking all commits up to $CURRENT_TAG"
git rev-list --reverse --no-merges $CURRENT_TAG | xargs -r git cherry-pick
else
echo "Cherry-picking commits from $PREV_TAG to $CURRENT_TAG"
git rev-list --reverse --no-merges $PREV_TAG..$CURRENT_TAG | xargs -r git cherry-pick
fi
./scripts/refresh-compat.sh
- name: Push compat
run: |
git push origin compat
git push origin compat --force

32
AGENTS.md Normal file
View File

@@ -0,0 +1,32 @@
# AGENTS.md
## Development Commands
- Build: You should not run build command.
- Test: `go test -ldflags="-checklinkname=0" ...`
## Documentation
Update package level `README.md` if exists after making significant changes.
## Go Guidelines
1. Use builtin `min` and `max` functions instead of creating custom ones
2. Prefer `for i := range 10` over `for i := 0; i < 10; i++`
3. Beware of variable shadowing when making edits
4. Use `internal/task/task.go` for lifetime management:
- `task.RootTask()` for background operations
- `parent.Subtask()` for nested tasks
- `OnFinished()` and `OnCancel()` callbacks for cleanup
5. Use `gperr "goutils/errs"` to build pretty nested errors:
- `gperr.Multiline()` for multiple operation attempts
- `gperr.NewBuilder()` to collect errors
- `gperr.NewGroup() + group.Go()` to collect errors of multiple concurrent operations
- `gperr.PrependSubject()` to prepend subject to errors
6. Use `github.com/puzpuzpuz/xsync/v4` for lock-free thread safe maps
7. Use `goutils/synk` to retrieve and put byte buffer
## Testing
- Run scoped tests instead of `./...`
- Use `testify`, no manual assertions.

View File

@@ -17,15 +17,22 @@ endif
LDFLAGS = -X github.com/yusing/goutils/version.version=${VERSION} -checklinkname=0
PACKAGE ?= ./cmd
ifeq ($(agent), 1)
NAME = godoxy-agent
PWD = ${shell pwd}/agent
else ifeq ($(socket-proxy), 1)
NAME = godoxy-socket-proxy
PWD = ${shell pwd}/socket-proxy
else ifeq ($(cli), 1)
NAME = godoxy-cli
PWD = ${shell pwd}/cmd/cli
PACKAGE = .
else
NAME = godoxy
PWD = ${shell pwd}
godoxy = 1
endif
ifeq ($(trace), 1)
@@ -58,7 +65,6 @@ endif
BUILD_FLAGS += -tags '$(GO_TAGS)' -ldflags='$(LDFLAGS)'
BIN_PATH := $(shell pwd)/bin/${NAME}
CLI_BIN_PATH ?= $(shell pwd)/bin/godoxy-cli
export NAME
export CGO_ENABLED
@@ -76,7 +82,11 @@ endif
# CAP_NET_BIND_SERVICE: permission for binding to :80 and :443
POST_BUILD = $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep ${BIN_PATH};
POST_BUILD = echo;
ifeq ($(godoxy), 1)
POST_BUILD += $(SETCAP_CMD) CAP_NET_BIND_SERVICE=+ep ${BIN_PATH};
endif
ifeq ($(docker), 1)
POST_BUILD += mkdir -p /app && mv ${BIN_PATH} /app/run;
endif
@@ -133,13 +143,18 @@ minify-js:
done \
fi
build: minify-js
build:
@if [ "${godoxy}" = "1" ]; then \
make minify-js; \
elif [ "${cli}" = "1" ]; then \
make gen-cli; \
fi
mkdir -p $(shell dirname ${BIN_PATH})
go build -C ${PWD} ${BUILD_FLAGS} -o ${BIN_PATH} ./cmd
go build -C ${PWD} ${BUILD_FLAGS} -o ${BIN_PATH} ${PACKAGE}
${POST_BUILD}
run: minify-js
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ./cmd
cd ${PWD} && [ -f .env ] && godotenv -f .env go run ${BUILD_FLAGS} ${PACKAGE}
dev:
docker compose -f dev.compose.yml $(args)
@@ -186,13 +201,10 @@ gen-api-types: gen-swagger
bunx --bun swagger-typescript-api generate --sort-types --generate-union-enums --axios --add-readonly --route-types \
--responses -o ${WEBUI_DIR}/src/lib -n api.ts -p internal/api/v1/docs/swagger.json
.PHONY: gen-cli build-cli update-wiki
gen-cli:
cd cmd/cli && go run ./gen
build-cli: gen-cli
mkdir -p $(shell dirname ${CLI_BIN_PATH})
go build -C cmd/cli -o ${CLI_BIN_PATH} .
.PHONY: gen-cli build-cli update-wiki
update-wiki:
DOCS_DIR=${DOCS_DIR} REPO_URL=${REPO_URL} bun --bun scripts/update-wiki/main.ts

Submodule goutils updated: 246c8e9ba1...4912690d40

View File

@@ -122,9 +122,9 @@ classDiagram
+accessLogger AccessLogger
+findRouteFunc findRouteFunc
+shortLinkMatcher *ShortLinkMatcher
+streamRoutes *pool.Pool[types.StreamRoute]
+excludedRoutes *pool.Pool[types.Route]
+servers *xsync.Map[string, *httpServer]
+streamRoutes *pool.Pool\[types.StreamRoute\]
+excludedRoutes *pool.Pool\[types.Route\]
+servers *xsync.Map\[string, *httpServer\]
+SupportProxyProtocol() bool
+StartAddRoute(r) error
+IterRoutes(yield)
@@ -132,7 +132,7 @@ classDiagram
}
class httpServer {
+routes *pool.Pool[types.HTTPRoute]
+routes *pool.Pool\[types.HTTPRoute\]
+ServeHTTP(w, r)
+AddRoute(route)
+DelRoute(route)
@@ -154,8 +154,8 @@ classDiagram
}
class ShortLinkMatcher {
+fqdnRoutes *xsync.Map[string, string]
+subdomainRoutes *xsync.Map[string, struct{}]
+fqdnRoutes *xsync.Map\[string, string\]
+subdomainRoutes *xsync.Map\[string, emptyStruct\]
+ServeHTTP(w, r)
+AddRoute(alias)
+DelRoute(alias)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/url"
"strings"
"syscall"
"time"
@@ -152,19 +153,24 @@ func (s *SystemInfo) collectDisksInfo(ctx context.Context, lastResult *SystemInf
}
}
partitions, err := disk.PartitionsWithContext(ctx, false)
partitions, err := disk.PartitionsWithContext(ctx, true)
if err != nil {
return err
}
s.Disks = make(map[string]disk.UsageStat, len(partitions))
errs := gperr.NewBuilder("failed to get disks info")
for _, partition := range partitions {
if !shouldCollectPartition(partition) {
continue
}
diskInfo, err := disk.UsageWithContext(ctx, partition.Mountpoint)
if err != nil {
errs.Add(err)
continue
}
s.Disks[partition.Device] = diskInfo
key := diskKey(partition)
s.Disks[key] = diskInfo
}
if errs.HasError() {
@@ -176,6 +182,41 @@ func (s *SystemInfo) collectDisksInfo(ctx context.Context, lastResult *SystemInf
return nil
}
func shouldCollectPartition(partition disk.PartitionStat) bool {
if partition.Mountpoint == "/" {
return true
}
if partition.Mountpoint == "" {
return false
}
// includes WSL mounts like /mnt/c, but exclude /mnt/ itself and /mnt/wsl*
if len(partition.Mountpoint) >= len("/mnt/") &&
strings.HasPrefix(partition.Mountpoint, "/mnt/") &&
!strings.HasPrefix(partition.Mountpoint, "/mnt/wsl") {
return true
}
if strings.HasPrefix(partition.Device, "/dev/") {
return true
}
return false
}
func diskKey(partition disk.PartitionStat) string {
if partition.Device == "" || partition.Device == "none" {
return partition.Mountpoint
}
if partition.Device == "/dev/root" {
return partition.Mountpoint
}
return partition.Device
}
func (s *SystemInfo) collectNetworkInfo(ctx context.Context, lastResult *SystemInfo) error {
networkIO, err := net.IOCountersWithContext(ctx, false)
if err != nil {

View File

@@ -122,6 +122,10 @@ func (c *checkBypass) modifyResponse(resp *http.Response) error {
return c.modRes.modifyResponse(resp)
}
func (c *checkBypass) requiresBodyRewrite() bool {
return requiresBodyRewrite(c.modRes)
}
func (m *Middleware) withCheckBypass() any {
if len(m.Bypass) > 0 {
modReq, _ := m.impl.(RequestModifier)

View File

@@ -20,6 +20,10 @@ var CustomErrorPage = NewMiddleware[customErrorPage]()
const StaticFilePathPrefix = "/$gperrorpage/"
func (customErrorPage) requiresBodyRewrite() bool {
return true
}
// before implements RequestModifier.
func (customErrorPage) before(w http.ResponseWriter, r *http.Request) (proceed bool) {
return !ServeStaticErrorPageFile(w, r)

View File

@@ -3,9 +3,11 @@ package middleware
import (
"fmt"
"maps"
"mime"
"net/http"
"reflect"
"sort"
"strconv"
"strings"
"github.com/bytedance/sonic"
@@ -17,6 +19,12 @@ import (
"github.com/yusing/goutils/http/reverseproxy"
)
const (
mimeEventStream = "text/event-stream"
headerContentType = "Content-Type"
maxModifiableBody = 4 * 1024 * 1024 // 4MB
)
type (
ReverseProxy = reverseproxy.ReverseProxy
ProxyRequest = reverseproxy.ProxyRequest
@@ -190,78 +198,112 @@ func (m *Middleware) ServeHTTP(next http.HandlerFunc, w http.ResponseWriter, r *
}
}
if httpheaders.IsWebsocket(r.Header) || r.Header.Get("Accept") == "text/event-stream" {
if httpheaders.IsWebsocket(r.Header) || strings.Contains(strings.ToLower(r.Header.Get("Accept")), mimeEventStream) {
next(w, r)
return
}
if exec, ok := m.impl.(ResponseModifier); ok {
rm := httputils.NewResponseModifier(w)
defer func() {
_, err := rm.FlushRelease()
if err != nil {
m.LogError(r).Err(err).Msg("failed to flush response")
}
}()
next(rm, r)
currentBody := rm.BodyReader()
currentResp := &http.Response{
StatusCode: rm.StatusCode(),
Header: rm.Header(),
ContentLength: int64(rm.ContentLength()),
Body: currentBody,
Request: r,
}
allowBodyModification := canModifyResponseBody(currentResp)
respToModify := currentResp
if !allowBodyModification {
shadow := *currentResp
shadow.Body = eofReader{}
respToModify = &shadow
}
if err := exec.modifyResponse(respToModify); err != nil {
log.Err(err).Str("middleware", m.Name()).Str("url", fullURL(r)).Msg("failed to modify response")
}
// override the response status code
rm.WriteHeader(respToModify.StatusCode)
// overriding the response header
maps.Copy(rm.Header(), respToModify.Header)
// override the content length and body if changed
if respToModify.Body != currentBody {
if allowBodyModification {
if err := rm.SetBody(respToModify.Body); err != nil {
m.LogError(r).Err(err).Msg("failed to set response body")
}
} else {
respToModify.Body.Close()
}
}
} else {
exec, ok := m.impl.(ResponseModifier)
if !ok {
next(w, r)
return
}
lrm := httputils.NewLazyResponseModifier(w, canBufferAndModifyResponseBody)
lrm.SetMaxBufferedBytes(maxModifiableBody)
defer func() {
_, err := lrm.FlushRelease()
if err != nil {
m.LogError(r).Err(err).Msg("failed to flush response")
}
}()
next(lrm, r)
// Skip modification if response wasn't buffered
if !lrm.IsBuffered() {
return
}
rm := lrm.ResponseModifier()
currentBody := rm.BodyReader()
currentResp := &http.Response{
StatusCode: rm.StatusCode(),
Header: rm.Header(),
ContentLength: int64(rm.ContentLength()),
Body: currentBody,
Request: r,
}
respToModify := currentResp
if err := exec.modifyResponse(respToModify); err != nil {
log.Err(err).Str("middleware", m.Name()).Str("url", fullURL(r)).Msg("failed to modify response")
return // skip modification if failed
}
// override the response status code
rm.WriteHeader(respToModify.StatusCode)
// overriding the response header
maps.Copy(rm.Header(), respToModify.Header)
// override the body if changed
if respToModify.Body != currentBody {
err := rm.SetBody(respToModify.Body)
if err != nil {
m.LogError(r).Err(err).Msg("failed to set response body")
return // skip modification if failed
}
}
}
func canModifyResponseBody(resp *http.Response) bool {
if hasNonIdentityEncoding(resp.TransferEncoding) {
// canBufferAndModifyResponseBody checks if the response body can be buffered and modified.
//
// A body can be buffered and modified if:
// - The response is not a websocket and is not an event stream
// - The response has identity transfer encoding
// - The response has identity content encoding
// - The response has a content length
// - The content length is less than 4MB
// - The content type is text-like
func canBufferAndModifyResponseBody(respHeader http.Header) bool {
if httpheaders.IsWebsocket(respHeader) {
return false
}
if hasNonIdentityEncoding(resp.Header.Values("Transfer-Encoding")) {
contentType := respHeader.Get("Content-Type")
if contentType == "" { // safe default: skip if no content type
return false
}
if hasNonIdentityEncoding(resp.Header.Values("Content-Encoding")) {
contentType = strings.ToLower(contentType)
if strings.Contains(contentType, mimeEventStream) {
return false
}
return isTextLikeMediaType(string(httputils.GetContentType(resp.Header)))
// strip charset or any other parameters
contentType, _, err := mime.ParseMediaType(contentType)
if err != nil { // skip if invalid content type
return false
}
if hasNonIdentityEncoding(respHeader.Values("Transfer-Encoding")) {
return false
}
if hasNonIdentityEncoding(respHeader.Values("Content-Encoding")) {
return false
}
if contentLengthRaw := respHeader.Get("Content-Length"); contentLengthRaw != "" {
contentLength, err := strconv.ParseInt(contentLengthRaw, 10, 64)
if err != nil || contentLength >= maxModifiableBody {
return false
}
}
if !isTextLikeMediaType(contentType) {
return false
}
return true
}
func hasNonIdentityEncoding(values []string) bool {
for _, value := range values {
for _, token := range strings.Split(value, ",") {
if strings.TrimSpace(token) == "" || strings.EqualFold(strings.TrimSpace(token), "identity") {
for token := range strings.SplitSeq(value, ",") {
token = strings.TrimSpace(token)
if token == "" || strings.EqualFold(token, "identity") {
continue
}
return true

View File

@@ -1,7 +1,7 @@
package middleware
import (
"maps"
"fmt"
"net/http"
"strconv"
@@ -13,6 +13,10 @@ type middlewareChain struct {
modResps []ResponseModifier
}
type bodyRewriteRequired interface {
requiresBodyRewrite() bool
}
// TODO: check conflict or duplicates.
func NewMiddlewareChain(name string, chain []*Middleware) *Middleware {
chainMid := &middlewareChain{}
@@ -47,23 +51,61 @@ func (m *middlewareChain) modifyResponse(resp *http.Response) error {
if len(m.modResps) == 0 {
return nil
}
allowBodyModification := canModifyResponseBody(resp)
for i, mr := range m.modResps {
respToModify := resp
if !allowBodyModification {
shadow := *resp
shadow.Body = eofReader{}
respToModify = &shadow
}
if err := mr.modifyResponse(respToModify); err != nil {
if err := modifyResponseWithBodyRewriteGate(mr, resp); err != nil {
return gperr.PrependSubject(err, strconv.Itoa(i))
}
if !allowBodyModification {
resp.StatusCode = respToModify.StatusCode
if respToModify.Header != nil {
maps.Copy(resp.Header, respToModify.Header)
}
}
}
return nil
}
func modifyResponseWithBodyRewriteGate(mr ResponseModifier, resp *http.Response) error {
originalBody := resp.Body
originalContentLength := resp.ContentLength
allowBodyRewrite := canBufferAndModifyResponseBody(responseHeaderForBodyRewriteGate(resp))
if !allowBodyRewrite && requiresBodyRewrite(mr) {
return nil
}
if err := mr.modifyResponse(resp); err != nil {
return err
}
if allowBodyRewrite || resp.Body == originalBody {
return nil
}
if resp.Body != nil {
if err := resp.Body.Close(); err != nil {
return fmt.Errorf("close rewritten body: %w", err)
}
}
if originalBody == nil || originalBody == http.NoBody {
resp.Body = http.NoBody
} else {
resp.Body = originalBody
}
resp.ContentLength = originalContentLength
if originalContentLength >= 0 {
resp.Header.Set("Content-Length", strconv.FormatInt(originalContentLength, 10))
} else {
resp.Header.Del("Content-Length")
}
return nil
}
func requiresBodyRewrite(mr ResponseModifier) bool {
required, ok := mr.(bodyRewriteRequired)
return ok && required.requiresBodyRewrite()
}
func responseHeaderForBodyRewriteGate(resp *http.Response) http.Header {
h := resp.Header.Clone()
if len(resp.TransferEncoding) > 0 && len(h.Values("Transfer-Encoding")) == 0 {
h["Transfer-Encoding"] = append([]string(nil), resp.TransferEncoding...)
}
if resp.ContentLength >= 0 && h.Get("Content-Length") == "" {
h.Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10))
}
return h
}

View File

@@ -1,8 +1,10 @@
package middleware
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
@@ -29,6 +31,29 @@ type testResponseRewrite struct {
Body string `json:"body"`
}
type closeSensitiveBody struct {
data []byte
offset int
closed bool
}
func (b *closeSensitiveBody) Read(p []byte) (int, error) {
if b.closed {
return 0, errors.New("http: read on closed response body")
}
if b.offset >= len(b.data) {
return 0, io.EOF
}
n := copy(p, b.data[b.offset:])
b.offset += n
return n, nil
}
func (b *closeSensitiveBody) Close() error {
b.closed = true
return nil
}
func (t testResponseRewrite) modifyResponse(resp *http.Response) error {
resp.StatusCode = t.StatusCode
resp.Header.Set(t.HeaderKey, t.HeaderVal)
@@ -127,9 +152,132 @@ func TestMiddlewareResponseRewriteGate(t *testing.T) {
respStatus: http.StatusOK,
})
expect.NoError(t, err)
expect.Equal(t, result.ResponseStatus, 418)
expect.Equal(t, result.ResponseStatus, http.StatusTeapot)
expect.Equal(t, result.ResponseHeaders.Get("X-Rewrite"), "1")
expect.Equal(t, string(result.Data), tc.expectBody)
})
}
}
func TestMiddlewareResponseRewriteGateServeHTTP(t *testing.T) {
opts := OptionsRaw{
"status_code": 418,
"header_key": "X-Rewrite",
"header_val": "1",
"body": "rewritten-body",
}
tests := []struct {
name string
respHeaders http.Header
respBody string
expectStatusCode int
expectHeader string
expectBody string
}{
{
name: "allow_body_rewrite_for_html",
respHeaders: http.Header{
"Content-Type": []string{"text/html; charset=utf-8"},
},
respBody: "<html><body>original</body></html>",
expectStatusCode: http.StatusTeapot,
expectHeader: "1",
expectBody: "rewritten-body",
},
{
name: "block_body_rewrite_for_binary_content",
respHeaders: http.Header{
"Content-Type": []string{"application/octet-stream"},
},
respBody: "binary",
expectStatusCode: http.StatusOK,
expectHeader: "",
expectBody: "binary",
},
{
name: "block_body_rewrite_for_transfer_encoded_html",
respHeaders: http.Header{
"Content-Type": []string{"text/html"},
"Transfer-Encoding": []string{"chunked"},
},
respBody: "<html><body>original</body></html>",
expectStatusCode: http.StatusOK,
expectHeader: "",
expectBody: "<html><body>original</body></html>",
},
{
name: "block_body_rewrite_for_content_encoded_html",
respHeaders: http.Header{
"Content-Type": []string{"text/html"},
"Content-Encoding": []string{"gzip"},
},
respBody: "<html><body>original</body></html>",
expectStatusCode: http.StatusOK,
expectHeader: "",
expectBody: "<html><body>original</body></html>",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mid, err := responseRewrite.New(opts)
expect.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
rw := httptest.NewRecorder()
next := func(w http.ResponseWriter, _ *http.Request) {
for key, values := range tc.respHeaders {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(tc.respBody))
}
mid.ServeHTTP(next, rw, req)
resp := rw.Result()
defer resp.Body.Close()
data, readErr := io.ReadAll(resp.Body)
expect.NoError(t, readErr)
expect.Equal(t, resp.StatusCode, tc.expectStatusCode)
expect.Equal(t, resp.Header.Get("X-Rewrite"), tc.expectHeader)
expect.Equal(t, string(data), tc.expectBody)
})
}
}
func TestMiddlewareResponseRewriteGateSkipsBodyRewriterWhenRewriteBlocked(t *testing.T) {
originalBody := &closeSensitiveBody{
data: []byte("<html><body>original</body></html>"),
}
req := httptest.NewRequest(http.MethodGet, "http://example.com", nil)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/html; charset=utf-8"},
"Transfer-Encoding": []string{"chunked"},
},
Body: originalBody,
ContentLength: -1,
TransferEncoding: []string{"chunked"},
Request: req,
}
themedMid, err := Themed.New(OptionsRaw{
"theme": DarkTheme,
})
expect.NoError(t, err)
respMod, ok := themedMid.impl.(ResponseModifier)
expect.True(t, ok)
expect.NoError(t, modifyResponseWithBodyRewriteGate(respMod, resp))
data, err := io.ReadAll(resp.Body)
expect.NoError(t, err)
expect.Equal(t, string(data), "<html><body>original</body></html>")
}

View File

@@ -22,6 +22,10 @@ type modifyHTML struct {
var ModifyHTML = NewMiddleware[modifyHTML]()
func (*modifyHTML) requiresBodyRewrite() bool {
return true
}
func (m *modifyHTML) before(_ http.ResponseWriter, req *http.Request) bool {
req.Header.Set("Accept-Encoding", "identity")
return true

View File

@@ -54,6 +54,10 @@ func (m *themed) modifyResponse(resp *http.Response) error {
return m.m.modifyResponse(resp)
}
func (*themed) requiresBodyRewrite() bool {
return true
}
func (m *themed) finalize() error {
m.m.Target = "body"
if m.FontURL != "" && m.FontFamily != "" {

View File

@@ -309,7 +309,8 @@ nested_block := on_expr ws* '{' do_body '}'
Notes:
- A nested block is recognized when a line ends with an unquoted `{` (ignoring trailing whitespace).
- A nested block is recognized when a logical header ends with an unquoted `{`.
- Logical headers can continue to the next line when the current line ends with `|` or `&`.
- `on_expr` uses the same syntax as rule `on` (supports `|`, `&`, quoting/backticks, matcher functions, etc.).
- The nested block executes **in sequence**, at the point where it appears in the parent `do` list.
- Nested blocks are evaluated in the same phase the parent rule runs (no special phase promotion).
@@ -424,6 +425,15 @@ path !glob("/public/*")
# OR within a line
method GET | method POST
# OR across multiple lines (line continuation)
method GET |
method POST |
method PUT
# AND across multiple lines
header Connection Upgrade &
header Upgrade websocket
```
### Variable Substitution

View File

@@ -292,6 +292,20 @@ func parseAtBlockChain(src string, blockPos int) (CommandHandler, int, error) {
}
func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool {
return lineEndsWithUnquotedToken(src, lineStart, lineEnd) == '{'
}
func lineContinuationOperator(src string, lineStart int, lineEnd int) byte {
token := lineEndsWithUnquotedToken(src, lineStart, lineEnd)
switch token {
case '|', '&':
return token
default:
return 0
}
}
func lineEndsWithUnquotedToken(src string, lineStart int, lineEnd int) byte {
quote := byte(0)
lastSignificant := byte(0)
atLineStart := true
@@ -334,13 +348,22 @@ func lineEndsWithUnquotedOpenBrace(src string, lineStart int, lineEnd int) bool
atLineStart = false
prevIsSpace = false
}
return quote == 0 && lastSignificant == '{'
if quote != 0 {
return 0
}
return lastSignificant
}
// parseDoWithBlocks parses a do-body containing plain command lines and nested blocks.
// It returns the outer command handlers and the require phase.
//
// A nested block is recognized when a line ends with an unquoted '{' (ignoring trailing whitespace).
// A nested block is recognized when a logical header ends with an unquoted '{'.
// Logical headers may span lines using trailing '|' or '&', for example:
//
// remote 127.0.0.1 |
// remote 192.168.0.0/16 {
// set header X-Remote-Type private
// }
func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
pos := 0
length := len(src)
@@ -400,12 +423,38 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
linePos++
}
lineEnd := linePos
for lineEnd < length && src[lineEnd] != '\n' {
lineEnd++
logicalEnd := linePos
for logicalEnd < length && src[logicalEnd] != '\n' {
logicalEnd++
}
if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, lineEnd) {
for linePos < length && lineContinuationOperator(src, linePos, logicalEnd) != 0 {
nextPos := logicalEnd
if nextPos < length && src[nextPos] == '\n' {
nextPos++
}
for nextPos < length {
c := rune(src[nextPos])
if c == '\n' {
nextPos++
continue
}
if c == '\r' || unicode.IsSpace(c) {
nextPos++
continue
}
break
}
if nextPos >= length {
break
}
logicalEnd = nextPos
for logicalEnd < length && src[logicalEnd] != '\n' {
logicalEnd++
}
}
if linePos < length && lineEndsWithUnquotedOpenBrace(src, linePos, logicalEnd) {
h, next, err := parseAtBlockChain(src, linePos)
if err != nil {
return nil, err
@@ -417,10 +466,10 @@ func parseDoWithBlocks(src string) (handlers []CommandHandler, err error) {
}
// Not a nested block; parse the rest of this line as a command.
if lerr := appendLineCommand(src[pos:lineEnd]); lerr != nil {
if lerr := appendLineCommand(src[pos:logicalEnd]); lerr != nil {
return nil, lerr
}
pos = lineEnd
pos = logicalEnd
lineStart = true
continue
}

View File

@@ -71,3 +71,38 @@ func TestIfElseBlockCommandServeHTTP_ConditionalMatchedNilDoNotFallsThrough(t *t
require.NoError(t, err)
assert.False(t, elseCalled)
}
func TestParseDoWithBlocks_MultilineBlockHeaderContinuation(t *testing.T) {
tests := []struct {
name string
src string
}{
{
name: "or continuation",
src: `
remote 127.0.0.1 |
remote 192.168.0.0/16 {
set header X-Remote-Type private
}
`,
},
{
name: "and continuation",
src: `
method GET &
remote 127.0.0.1 {
set header X-Remote-Type private
}
`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
handlers, err := parseDoWithBlocks(tt.src)
require.NoError(t, err)
require.Len(t, handlers, 1)
require.IsType(t, IfBlockCommand{}, handlers[0])
})
}
}

View File

@@ -456,7 +456,8 @@ func TestHTTPFlow_NestedBlocks_RemoteOverride(t *testing.T) {
err := parseRules(`
header X-Test-Header {
set header X-Remote-Type public
remote 127.0.0.1 | remote 192.168.0.0/16 {
remote 127.0.0.1 |
remote 192.168.0.0/16 {
set header X-Remote-Type private
}
}

View File

@@ -505,62 +505,70 @@ var (
andSeps = [256]uint8{'&': 1, '\n': 1}
)
func indexAnd(s string) int {
for i := range s {
if andSeps[s[i]] != 0 {
return i
}
}
return -1
}
func countAnd(s string) int {
n := 0
for i := range s {
if andSeps[s[i]] != 0 {
n++
}
}
return n
}
// splitAnd splits a string by "&" and "\n" with all spaces removed.
// empty strings are not included in the result.
// splitAnd splits a condition string into AND parts.
// It treats '&' and newline as AND separators, except when a line ends with
// an unescaped '|' (OR continuation), where the newline stays in the same part.
// Empty parts are omitted.
func splitAnd(s string) []string {
if s == "" {
return []string{}
}
n := countAnd(s)
a := make([]string, n+1)
i := 0
for i < n {
end := indexAnd(s)
if end == -1 {
break
result := []string{}
forEachAndPart(s, func(part string) {
result = append(result, part)
})
return result
}
func lineEndsWithUnescapedPipe(s string, start, end int) bool {
for i := end - 1; i >= start; i-- {
if asciiSpace[s[i]] != 0 {
continue
}
beg := 0
// trim leading spaces
for beg < end && asciiSpace[s[beg]] != 0 {
beg++
if s[i] != '|' {
return false
}
// trim trailing spaces
next := end + 1
for end-1 > beg && asciiSpace[s[end-1]] != 0 {
end--
escapes := 0
for j := i - 1; j >= start && s[j] == '\\'; j-- {
escapes++
}
// skip empty segments
if end > beg {
a[i] = s[beg:end]
i++
}
s = s[next:]
return escapes%2 == 0
}
s = strings.TrimSpace(s)
if s != "" {
a[i] = s
i++
return false
}
func advanceSplitState(s string, i *int, quote *byte, brackets *int) bool {
c := s[*i]
if *quote != 0 {
if c == '\\' && *i+1 < len(s) {
*i++
return true
}
if c == *quote {
*quote = 0
}
return true
}
return a[:i]
switch c {
case '\\':
if *i+1 < len(s) {
*i++
return true
}
case '"', '\'', '`':
*quote = c
return true
case '(':
*brackets++
return true
case ')':
if *brackets > 0 {
*brackets--
}
return true
}
return false
}
// splitPipe splits a string by "|" but respects quotes, brackets, and escaped characters.
@@ -578,8 +586,26 @@ func splitPipe(s string) []string {
}
func forEachAndPart(s string, fn func(part string)) {
quote := byte(0)
brackets := 0
start := 0
for i := 0; i <= len(s); i++ {
if i < len(s) {
c := s[i]
if advanceSplitState(s, &i, &quote, &brackets) {
continue
}
if c == '\n' {
if brackets > 0 || lineEndsWithUnescapedPipe(s, start, i) {
continue
}
} else if c != '&' || brackets > 0 {
continue
}
}
if i < len(s) && andSeps[s[i]] == 0 {
continue
}
@@ -597,30 +623,14 @@ func forEachPipePart(s string, fn func(part string)) {
start := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
if i+1 < len(s) {
i++
}
case '"', '\'', '`':
if quote == 0 && brackets == 0 {
quote = s[i]
} else if s[i] == quote {
quote = 0
}
case '(':
brackets++
case ')':
if brackets > 0 {
brackets--
}
case '|':
if quote == 0 && brackets == 0 {
if part := strings.TrimSpace(s[start:i]); part != "" {
fn(part)
}
start = i + 1
if advanceSplitState(s, &i, &quote, &brackets) {
continue
}
if s[i] == '|' && brackets == 0 {
if part := strings.TrimSpace(s[start:i]); part != "" {
fn(part)
}
start = i + 1
}
}
if start < len(s) {

View File

@@ -1,6 +1,8 @@
package rules
import (
"net/http"
"net/url"
"testing"
gperr "github.com/yusing/goutils/errs"
@@ -133,6 +135,16 @@ func TestSplitAnd(t *testing.T) {
input: " rule1\nrule2 & rule3 ",
want: []string{"rule1", "rule2", "rule3"},
},
{
name: "newline_after_pipe_is_or_continuation",
input: "path /abc |\npath /bcd",
want: []string{"path /abc |\npath /bcd"},
},
{
name: "newline_after_pipe_with_spaces_is_or_continuation",
input: "path /abc | \n path /bcd",
want: []string{"path /abc | \n path /bcd"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -280,6 +292,11 @@ func TestParseOn(t *testing.T) {
input: `method GET | path regex("^(_next/static|_next/image|favicon.ico).*$") | header Authorization`,
wantErr: nil,
},
{
name: "pipe_multiline_continuation",
input: "path /abc |\npath /bcd |",
wantErr: nil,
},
}
for _, tt := range tests {
@@ -294,3 +311,18 @@ func TestParseOn(t *testing.T) {
})
}
}
func TestRuleOnParse_MultilineOrContinuation(t *testing.T) {
var on RuleOn
err := on.Parse("path /abc |\npath /bcd |")
expect.NoError(t, err)
w := http.ResponseWriter(nil)
reqABC := &http.Request{URL: &url.URL{Path: "/abc"}}
reqBCD := &http.Request{URL: &url.URL{Path: "/bcd"}}
reqXYZ := &http.Request{URL: &url.URL{Path: "/xyz"}}
expect.Equal(t, on.Check(w, reqABC), true)
expect.Equal(t, on.Check(w, reqBCD), true)
expect.Equal(t, on.Check(w, reqXYZ), false)
}

View File

@@ -27,22 +27,21 @@ type (
Example:
proxy.app1.rules: |
- name: default
do: |
rewrite / /index.html
serve /var/www/goaccess
- name: ws
on: |
header Connection Upgrade
header Upgrade websocket
do: bypass
default {
rewrite / /index.html
serve /var/www/goaccess
}
header Connection Upgrade & header Upgrade websocket {
bypass
}
proxy.app2.rules: |
- name: default
do: bypass
- name: block POST and PUT
on: method POST | method PUT
do: error 403 Forbidden
default {
bypass
}
method POST | method PUT {
error 403 Forbidden
}
*/
//nolint:recvcheck
Rules []Rule

55
scripts/refresh-compat.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/bin/bash
set -euo pipefail
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree is not clean. Commit or stash changes before running refresh-compat.sh." >&2
exit 1
fi
git fetch origin main compat
git checkout -B compat origin/compat
patch_file="$(mktemp)"
trap 'rm -f "$patch_file"' EXIT
git diff origin/main -- ':(glob)**/*.go' >"$patch_file"
git checkout -B main origin/main
git branch -D compat
git checkout -b compat
git apply "$patch_file"
mapfile -t changed_go_files < <(git diff --name-only -- '*.go')
fmt_go_files=()
for file in "${changed_go_files[@]}"; do
[ -f "$file" ] || continue
sed -i 's/sonic\./json\./g' "$file"
sed -i 's/"github.com\/bytedance\/sonic"/"encoding\/json"/g' "$file"
sed -E -i 's/\bsonic[[:space:]]+"encoding\/json"/json "encoding\/json"/g' "$file"
fmt_go_files+=("$file")
done
if [ "${#fmt_go_files[@]}" -gt 0 ]; then
gofmt -w "${fmt_go_files[@]}"
fi
# create placeholder files for minified JS files so go vet won't complain
while IFS= read -r file; do
ext="${file##*.}"
base="${file%.*}"
min_file="${base}-min.${ext}"
[ -f "$min_file" ] || : >"$min_file"
done < <(find internal/ -name '*.js' ! -name '*-min.js')
docker_version="$(
git show origin/compat:go.mod |
sed -n 's/^[[:space:]]*github.com\/docker\/docker[[:space:]]\+\(v[^[:space:]]\+\).*/\1/p' |
head -n 1
)"
if [ -n "$docker_version" ]; then
go mod edit -droprequire=github.com/docker/docker/api || true
go mod edit -droprequire=github.com/docker/docker/client || true
go mod edit -require="github.com/docker/docker@${docker_version}"
fi
go mod tidy
go mod -C agent tidy
git add -A
git commit -m "Apply compat patch"
go vet ./...
go vet -C agent ./...