mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-01 12:07:42 +01:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59238adb5b | ||
|
|
5f48f141ca | ||
|
|
a0adc51269 | ||
|
|
c002055892 | ||
|
|
d5406fb039 | ||
|
|
1bd8b5a696 | ||
|
|
79327e98bd | ||
|
|
206f69d249 | ||
|
|
3f6b09d05e | ||
|
|
af68eb4b18 | ||
|
|
9927267149 | ||
|
|
af8cddc1b2 | ||
|
|
c74da5cba9 | ||
|
|
c23cf8ef06 | ||
|
|
733716ba2b |
10
.github/workflows/cli-binary.yml
vendored
10
.github/workflows/cli-binary.yml
vendored
@@ -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 }}
|
||||
|
||||
21
.github/workflows/merge-main-into-compat.yml
vendored
21
.github/workflows/merge-main-into-compat.yml
vendored
@@ -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
32
AGENTS.md
Normal 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.
|
||||
32
Makefile
32
Makefile
@@ -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
|
||||
|
||||
2
goutils
2
goutils
Submodule goutils updated: 246c8e9ba1...4912690d40
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "e, &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, "e, &brackets) {
|
||||
continue
|
||||
}
|
||||
if s[i] == '|' && brackets == 0 {
|
||||
if part := strings.TrimSpace(s[start:i]); part != "" {
|
||||
fn(part)
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
55
scripts/refresh-compat.sh
Executable 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 ./...
|
||||
Reference in New Issue
Block a user