Compare commits

...

53 Commits
0.5.8 ... 0.6.1

Author SHA1 Message Date
yusing
d89155a6ee idlewatcher fixed idlewatcher incorrect respond haviour, keep url path 2024-10-07 16:44:18 +08:00
yusing
921ce23dde refactored http import name, fixed and simplified idlewatcher/idlewaker implementation, dependencies update 2024-10-07 12:45:07 +08:00
yusing
929b7f7059 get back aa6fafd5, accidentally reverted in 03cad9f3 2024-10-07 00:06:29 +08:00
yusing
de7805f281 fixed idlewatcher panics and incorrect behavior, update screenshot 2024-10-06 16:17:52 +08:00
yusing
03cad9f315 added package version api, dependencies upgrade 2024-10-06 09:23:41 +08:00
yusing
aa6fafd52f improved tracing for debug 2024-10-06 06:06:29 +08:00
yusing
01ff63a007 fix forward auth attempt#1 2024-10-06 03:18:06 +08:00
yusing
99746bad8e fix attempt#1: int64 not assignable to int 2024-10-06 02:02:13 +08:00
yusing
21b67e97af websocket fix attempt#2 2024-10-06 01:21:35 +08:00
yusing
668639e484 websocket fix attempt 2024-10-06 00:09:14 +08:00
yusing
e9b2079599 duration formatting update 2024-10-05 09:58:56 +08:00
yusing
5fb7d21c80 fixed that error message with sensitive info shouldn't be shown to end user 2024-10-05 03:42:09 +08:00
yusing
f5e00a6ef4 oops, adding back proxy.exclude=1 2024-10-04 19:07:48 +08:00
yusing
b06cbc0fee fixed dashboard stats update 2024-10-04 18:52:31 +08:00
yusing
abbcbad5e9 readme updates, docs moved to wiki 2024-10-04 11:27:11 +08:00
yusing
fab39a461f added ls-icons command 2024-10-04 10:04:18 +08:00
yusing
9c3edff92b databases without explicit alias(es) are now excluded by default 2024-10-04 09:17:45 +08:00
yusing
e8f4cd18a4 refactor: moved models/ to types/ 2024-10-04 08:47:53 +08:00
yusing
e566fd9b57 fixed homepage not respecting homepage.show field, disabled schema validation for included file 2024-10-04 08:36:32 +08:00
yusing
6211ddcdf0 show docker provider name instead of address in log 2024-10-04 07:21:49 +08:00
yusing
245f073350 tuned some http settings, refactor 2024-10-04 07:13:52 +08:00
yusing
dd629f516b omit EOF and contextCanceled error on non-debug mode 2024-10-04 06:55:43 +08:00
yusing
31080edd59 fixed event name missing 2024-10-04 06:51:26 +08:00
yusing
b679655cd5 fixed dashboard incorrect stats 2024-10-04 06:38:27 +08:00
yusing
ca3b062f89 updated schema for homepage fields 2024-10-04 01:00:06 +08:00
yusing
de6c1be51b improved homepage support, memory leak partial fix 2024-10-03 20:02:43 +08:00
yusing
4f09dbf044 replace - _ with whitespace for default homepage.name 2024-10-03 10:19:31 +08:00
yusing
e6b4630ce9 experimental homepage labels support 2024-10-03 10:10:14 +08:00
yusing
90bababd38 improved homepage labels 2024-10-03 04:00:02 +08:00
yusing
90130411f9 initial support of homepage labels 2024-10-03 02:53:05 +08:00
yusing
ae61a2335d added v1/list/match_domains 2024-10-03 02:13:34 +08:00
yusing
8329a8ea9c replacing label parser map with improved deserialization implementation, API host check now disabled when in debug mode 2024-10-03 01:50:49 +08:00
yusing
ef52ccb929 fixed api, fixed ListFiles function 2024-10-02 17:34:35 +08:00
yusing
ed9d8aab6f fixed docs 2024-10-02 17:33:41 +08:00
yusing
aa16287447 fixed route gone after container restart / Brename 2024-10-02 15:38:36 +08:00
yusing
a7a922308e fixed streams with zero port being served 2024-10-02 14:01:36 +08:00
yusing
ba13b81b0e fixed middleware implementation, added middleware tracing for easier debug 2024-10-02 13:55:41 +08:00
yusing
d172552fb0 fixed docs 2024-10-02 01:33:52 +08:00
yusing
2a8ab27fc1 fixed docs 2024-10-02 01:28:55 +08:00
yusing
e8c3e4c75f added cidr_whitelist middleware 2024-10-02 01:20:25 +08:00
yusing
ed887a5cfc fixed serialization and middleware compose 2024-10-02 01:04:34 +08:00
yusing
1bac96dc2a update docker test 2024-10-02 01:02:59 +08:00
yusing
c3b779a810 containers without port mapped will no longer be served 2024-10-01 17:18:17 +08:00
yusing
44cfd65f6c implement middleware compose 2024-10-01 16:38:07 +08:00
yusing
f5a36f94bb fixed error subject missing in some cases 2024-10-01 05:14:56 +08:00
yusing
e951194bee fixed route not being updated on restart, added experimental middleware compose support 2024-09-30 19:00:27 +08:00
yusing
478311fe9e fixed container routes not being loaded, added X-Forwarded-{Scheme,Proto,Host}, fixed containers with no mapping being served 2024-09-30 18:04:47 +08:00
yusing
48dd1397e8 remove sensitive info from debug logging 2024-09-30 16:32:58 +08:00
yusing
ebedbc931f enables add-x-forwarded by default, added hide-x-forwarded 2024-09-30 16:16:56 +08:00
yusing
9065d990e5 go-proxy ls-route now query api server first, then fallback to read from config file 2024-09-30 15:56:03 +08:00
yusing
b38d7595a7 fixed issue for container not being excluded on restart 2024-09-30 15:19:59 +08:00
yusing
860e914b90 added real_ip and cloudflare_real_ip middlewares, fixed that some middlewares does not work properly 2024-09-30 04:03:48 +08:00
yusing
ac3af49aa7 update compose example 2024-09-29 11:46:54 +08:00
123 changed files with 3138 additions and 2512 deletions

View File

@@ -24,8 +24,8 @@ jobs:
matrix:
platform:
- linux/amd64
- linux/arm/v6
- linux/arm/v7
# - linux/arm/v6
# - linux/arm/v7
- linux/arm64
steps:
- name: Prepare
@@ -61,6 +61,8 @@ jobs:
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ github.ref_name }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
@@ -124,9 +126,3 @@ jobs:
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
- name: Tag as latest
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-')
run: |
docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

3
.gitignore vendored
View File

@@ -1,4 +1,5 @@
compose.yml
*.compose.yml
config*/
certs*/
@@ -19,3 +20,5 @@ todo.md
.*.swp
.aider*
mtrace.json
.env

View File

@@ -11,5 +11,5 @@ build-image:
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
script:
- echo building $CI_REGISTRY_IMAGE
- docker build --pull -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE
- docker build --no-cache --build-arg VERSION=$CI_COMMIT_REF_NAME -t $CI_REGISTRY_IMAGE .
- docker push $CI_REGISTRY_IMAGE

View File

@@ -5,9 +5,7 @@
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml",
"providers.yml"
"providers.example.yml"
]
}
}
}

View File

@@ -1,25 +1,32 @@
# Stage 1: Builder
FROM golang:1.23.1-alpine AS builder
RUN apk add --no-cache tzdata
FROM golang:1.23.2-alpine AS builder
RUN apk add --no-cache tzdata make
WORKDIR /src
# Only copy go.mod and go.sum initially for better caching
COPY go.mod go.sum /src
COPY go.mod go.sum /src/
# Utilize build cache
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod graph | awk '{if ($1 !~ "@") print $2}' | xargs go get
go mod download -x
ENV GOCACHE=/root/.cache/go-build
# Build the application with better caching
ARG VERSION
ENV VERSION=${VERSION}
COPY scripts /src/scripts
COPY Makefile /src/
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
--mount=type=bind,src=cmd,dst=/src/cmd \
--mount=type=bind,src=internal,dst=/src/internal \
CGO_ENABLED=0 GOOS=linux go build -ldflags '-w -s' -pgo=auto -o /app/go-proxy ./cmd && \
mkdir /app/error_pages /app/certs
--mount=type=bind,src=pkg,dst=/src/pkg \
make build && \
mkdir -p /app/error_pages /app/certs && \
mv bin/go-proxy /app/go-proxy
# Stage 2: Final image
FROM scratch

View File

@@ -1,21 +1,19 @@
BUILD_FLAG ?= -s -w
VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_FLAGS ?= -s -w -X github.com/yusing/go-proxy/pkg.version=${VERSION}
export VERSION
export BUILD_FLAGS
export CGO_ENABLED = 0
export GOOS = linux
.PHONY: all setup build test up restart logs get debug run archive repush rapid-crash debug-list-containers
all: debug
setup:
mkdir -p config certs
[ -f config/config.yml ] || cp config.example.yml config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml
build:
mkdir -p bin
CGO_ENABLED=0 GOOS=linux \
go build -ldflags '${BUILD_FLAG}' -pgo=auto -o bin/go-proxy ./cmd
scripts/build.sh
test:
go test ./internal/...
GOPROXY_TEST=1 go test ./internal/...
up:
docker compose up -d
@@ -27,10 +25,16 @@ logs:
docker compose logs -f
get:
cd cmd && go get -u && go mod tidy && cd ..
go get -u ./cmd && go mod tidy
debug:
make BUILD_FLAG="" build && sudo GOPROXY_DEBUG=1 bin/go-proxy
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
mtrace:
bin/go-proxy debug-ls-mtrace > mtrace.json
run-test:
make build && sudo GOPROXY_TEST=1 bin/go-proxy
run:
make build && sudo bin/go-proxy

View File

@@ -9,7 +9,11 @@
[繁體中文文檔請看此](README_CHT.md)
A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse proxy with a web UI.
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
![Screenshot](screenshots/webui.png)
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
## Table of content
@@ -17,34 +21,30 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [Key Features](#key-features)
- [Getting Started](#getting-started)
- [Setup](#setup)
- [Commands line arguments](#commands-line-arguments)
- [Environment variables](#environment-variables)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Config File](#config-file)
- [Include Files](#include-files)
- [Showcase](#showcase)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Build it yourself](#build-it-yourself)
## Key Points
## Key Features
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto SSL cert management (See [Supported DNS Challenge Providers](docs/dns_providers.md))
- Auto SSL cert management (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [showcase](#idlesleeper))_
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP(s) reserve proxy
- [HTTP middleware support](docs/middlewares.md) _(experimental)_
- [Custom error pages support](docs/middlewares.md#custom-error-pages)
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP and UDP port forwarding
- Web UI for configuration and monitoring (See [screenshots](https://github.com/yusing/go-proxy-frontend?tab=readme-ov-file#screenshots))
- Supports linux/amd64, linux/arm64, linux/arm/v7, linux/arm/v6 multi-platform
- **Web UI with App dashboard**
- Supports linux/amd64, linux/arm64
- Written in **[Go](https://go.dev)**
[🔼Back to top](#table-of-content)
@@ -70,64 +70,31 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
4. Setup `docker-socket-proxy` other docker nodes _(if any)_ (see [example](docs/docker_socket_proxy.md)) and then them inside `config.yml`
4. Setup `docker-socket-proxy` other docker nodes _(if any)_ (see [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) and then them inside `config.yml`
5. Done. You may now do some extra configuration
5. Run go-proxy `docker compose up -d`
then list all routes to see if further configurations are needed:
`docker exec go-proxy /app/go-proxy ls-routes`
6. You may now do some extra configuration
- With text editor (e.g. Visual Studio Code)
- With Web UI via `gp.y.z`
- For more info, [See docker.md](docs/docker.md)
- With Web UI via `http://localhost:3000` or `https://gp.y.z`
- For more info, [See Wiki]([wiki](https://github.com/yusing/go-proxy/wiki))
[🔼Back to top](#table-of-content)
### Commands line arguments
| Argument | Description | Example |
| ----------- | -------------------------------- | -------------------------- |
| empty | start proxy server | |
| `validate` | validate config and exit | |
| `reload` | trigger a force reload of config | |
| `ls-config` | list config and exit | `go-proxy ls-config \| jq` |
| `ls-route` | list proxy entries and exit | `go-proxy ls-route \| jq` |
**run with `docker exec go-proxy /app/go-proxy <command>`**
### Environment variables
| Environment Variable | Description | Default | Values |
| ------------------------------ | ------------------------------------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | disable schema validation | `false` | boolean |
| `GOPROXY_DEBUG` | enable debug behaviors | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http server listening address | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https server listening address (if enabled) | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api server listening address | `127.0.0.1:8888` | `[host]:port` |
### Use JSON Schema in VSCode
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
[🔼Back to top](#table-of-content)
### Config File
See [config.example.yml](config.example.yml)
[🔼Back to top](#table-of-content)
### Include Files
These are files that include standalone proxy entries
See [Fields](docs/docker.md#fields)
See [providers.example.yml](providers.example.yml) for examples
[🔼Back to top](#table-of-content)
## Showcase
## Screenshots
### idlesleeper
![idlesleeper](showcase/idlesleeper.webp)
![idlesleeper](screenshots/idlesleeper.webp)
[🔼Back to top](#table-of-content)

View File

@@ -7,7 +7,7 @@
[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![](https://dcbadge.limes.pink/api/server/umReR62nRd)](https://discord.gg/umReR62nRd)
一個輕量化、易用且[高效](docs/benchmark_result.md)的反向代理和端口轉發工具
一個輕量化、易用且[高效]([docs/benchmark_result.md](https://github.com/yusing/go-proxy/wiki/Benchmarks)))的反向代理和端口轉發工具
## 目錄
@@ -21,8 +21,6 @@
- [命令行參數](#命令行參數)
- [環境變量](#環境變量)
- [VSCode 中使用 JSON Schema](#vscode-中使用-json-schema)
- [配置文件](#配置文件)
- [透過文件配置](#透過文件配置)
- [展示](#展示)
- [idlesleeper](#idlesleeper)
- [源碼編譯](#源碼編譯)
@@ -33,14 +31,16 @@
- 不需花費太多時間就能輕鬆配置
- 支持多個docker節點
- 除錯簡單
- 自動配置 SSL 證書(參見[可用的 DNS 供應商](docs/dns_providers.md)
- 自動配置 SSL 證書(參見[可用的 DNS 供應商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers)
- 透過 Docker 容器自動配置
- 容器狀態變更時自動熱重載
- 容器閒置時自動暫停/停止,入站時自動喚醒
- **idlesleeper** 容器閒置時自動暫停/停止,入站時自動喚醒 (可選, 參見 [展示](#idlesleeper))
- HTTP(s) 反向代理
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [自訂 error pages](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP/UDP 端口轉發
- 用於配置和監控的前端 Web 面板([截圖](https://github.com/yusing/go-proxy-frontend?tab=readme-ov-file#screenshots)
- 支持 linux/amd64、linux/arm64、linux/arm/v7、linux/arm/v6 多平台
- Web 面板 (內置App dashboard)
- 支持 linux/amd64、linux/arm64 平台
- 使用 **[Go](https://go.dev)** 編寫
[🔼 返回頂部](#目錄)
@@ -70,20 +70,23 @@
5. 大功告成,你可以做一些額外的配置
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
- 或通過 `http://gp.y.z` 使用網頁配置編輯器
- 或通過 `http://localhost:3000` 使用網頁配置編輯器
- 詳情請參閱 [docker.md](docs/docker.md)
[🔼 返回頂部](#目錄)
### 命令行參數
| 參數 | 描述 | 示例 |
| ----------- | -------------- | -------------------------- |
| 空 | 啟動代理服務器 | |
| `validate` | 驗證配置並退出 | |
| `reload` | 強制刷新配置 | |
| `ls-config` | 列出配置並退出 | `go-proxy ls-config \| jq` |
| `ls-route` | 列出路由並退出 | `go-proxy ls-route \| jq` |
| 參數 | 描述 | 示例 |
| ------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- |
| 空 | 啟動代理服務器 | |
| `validate` | 驗證配置並退出 | |
| `reload` | 強制刷新配置 | |
| `ls-config` | 列出配置並退出 | `go-proxy ls-config \| jq` |
| `ls-route` | 列出路由並退出 | `go-proxy ls-route \| jq` |
| `go-proxy ls-route \| jq` |
| `ls-icons` | 列出 [dashboard-icons](https://github.com/walkxcode/dashboard-icons/tree/main) 並退出 | `go-proxy ls-icons \| grep adguard` |
| `debug-ls-mtrace` | 列出middleware追蹤 **(僅限於 debug 模式)** | `go-proxy debug-ls-mtrace \| jq` |
**使用 `docker exec go-proxy /app/go-proxy <參數>` 運行**
@@ -103,25 +106,12 @@
[🔼 返回頂部](#目錄)
### 配置文件
參見 [config.example.yml](config.example.yml)
[🔼 返回頂部](#目錄)
### 透過文件配置
參見 [Fields](docs/docker.md#fields)
參見範例 [providers.example.yml](providers.example.yml)
[🔼 返回頂部](#目錄)
## 展示
### idlesleeper
![idlesleeper](showcase/idlesleeper.webp)
![idlesleeper](screenshots/idlesleeper.webp)
[🔼 返回頂部](#目錄)

View File

@@ -18,15 +18,17 @@ import (
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/api"
apiUtils "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/middleware"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/server"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/pkg"
)
func main() {
@@ -53,10 +55,11 @@ func main() {
ForceColors: true,
TimestampFormat: "01-02 15:04:05",
})
logrus.Infof("go-proxy version %s", pkg.GetVersion())
}
if args.Command == common.CommandReload {
if err := apiUtils.ReloadServer(); err.HasError() {
if err := query.ReloadServer(); err != nil {
log.Fatal(err)
}
log.Print("ok")
@@ -80,8 +83,9 @@ func main() {
prepareDirectory(dir)
}
err := config.Load()
if err != nil {
middleware.LoadComposeFiles()
if err := config.Load(); err != nil {
logrus.Warn(err)
}
cfg := config.GetInstance()
@@ -91,7 +95,21 @@ func main() {
printJSON(cfg.Value())
return
case common.CommandListRoutes:
printJSON(cfg.RoutesByAlias())
routes, err := query.ListRoutes()
if err != nil {
log.Printf("failed to connect to api server: %s", err)
log.Printf("falling back to config file")
printJSON(cfg.RoutesByAlias())
} else {
printJSON(routes)
}
return
case common.CommandListIcons:
icons, err := internal.ListAvailableIcons()
if err != nil {
log.Fatal(err)
}
printJSON(icons)
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
@@ -99,18 +117,15 @@ func main() {
case common.CommandDebugListProviders:
printJSON(cfg.DumpProviders())
return
}
if common.IsDebug {
printJSON(docker.GetRegisteredNamespaces())
case common.CommandDebugListMTrace:
trace, err := query.ListMiddlewareTraces()
if err != nil {
log.Fatal(err)
}
printJSON(trace)
}
cfg.StartProxyProviders()
if err.HasError() {
l.Warn(err)
}
cfg.WatchChanges()
onShutdown.Add(docker.CloseAllClients)
@@ -125,7 +140,7 @@ func main() {
if autocert != nil {
ctx, cancel := context.WithCancel(context.Background())
if err = autocert.Setup(ctx); err != nil {
if err := autocert.Setup(ctx); err != nil {
l.Fatal(err)
} else {
onShutdown.Add(cancel)
@@ -207,7 +222,7 @@ func funcName(f func()) string {
func printJSON(obj any) {
j, err := E.Check(json.MarshalIndent(obj, "", " "))
if err.HasError() {
if err != nil {
logrus.Fatal(err)
}
rawLogger := log.New(os.Stdout, "", 0)

View File

@@ -1,33 +1,45 @@
services:
frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
labels:
- proxy.aliases=gp
- proxy.gp.port=3000
depends_on:
- app
app:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host
environment:
# (Optional) change this to your timezone to get correct log timestamp
TZ: ETC/UTC
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
depends_on:
- app
# if you also want to proxy the WebUI and access it via gp.y.z
# labels:
# - proxy.aliases=gp
# - proxy.gp.port=3000
# (Optional) choose one of below to enable https
# 1. use existing certificate
# if your cert is not named `cert.crt` change `cert_path` in `config/config.yml`
# if your cert key is not named `priv.key` change `key_path` in `config/config.yml`
# Make sure the value is same as `GOPROXY_API_ADDR` below (if you have changed it)
#
# environment:
# GOPROXY_API_ADDR: 127.0.0.1:8888
app:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host
environment:
# (Optional) change this to your timezone to get correct log timestamp
TZ: ETC/UTC
# - /path/to/certs:/app/certs
# Change these if you need
#
# GOPROXY_HTTP_ADDR: :80
# GOPROXY_HTTPS_ADDR: :443
# GOPROXY_API_ADDR: 127.0.0.1:8888
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
# (Optional) choose one of below to enable https
# 1. use existing certificate
# if your cert is not named `cert.crt` change `cert_path` in `config/config.yml`
# if your cert key is not named `priv.key` change `key_path` in `config/config.yml`
# - ./certs:/app/certs
# - /path/to/certs:/app/certs
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
# - ./certs:/app/certs

View File

@@ -1,41 +0,0 @@
# Adding provider support
## **CloudDNS** as an example
1. Fork this repo, modify [autocert.go](../src/go-proxy/autocert.go#L305)
```go
var providersGenMap = map[string]ProviderGenerator{
"cloudflare": providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
// add here, e.g.
"clouddns": providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
}
```
2. Go to [https://go-acme.github.io/lego/dns/clouddns](https://go-acme.github.io/lego/dns/clouddns/) and check for required config
3. Build `go-proxy` with `make build`
4. Set required config in `config.yml` `autocert` -> `options` section
```shell
# From https://go-acme.github.io/lego/dns/clouddns/
CLOUDDNS_CLIENT_ID=bLsdFAks23429841238feb177a572aX \
CLOUDDNS_EMAIL=you@example.com \
CLOUDDNS_PASSWORD=b9841238feb177a84330f \
lego --email you@example.com --dns clouddns --domains my.example.org run
```
Should turn into:
```yaml
autocert:
...
options:
client_id: bLsdFAks23429841238feb177a572aX
email: you@example.com
password: b9841238feb177a84330f
```
5. Run with `GOPROXY_NO_SCHEMA_VALIDATION=1` and test if it works
6. Commit and create pull request

View File

@@ -1,104 +0,0 @@
# Benchmarks
Benchmarked with `wrk` and `traefik/whoami`'s `/bench` endpoint
## Remote benchmark
- Direct connection
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench
Running 10s test @ http://10.0.100.3:8003/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 94.75ms 199.92ms 1.68s 91.27%
Req/Sec 4.24k 1.79k 18.79k 72.13%
Latency Distribution
50% 1.14ms
75% 120.23ms
90% 245.63ms
99% 1.03s
423444 requests in 10.10s, 50.88MB read
Socket errors: connect 0, read 0, write 0, timeout 29
Requests/sec: 41926.32
Transfer/sec: 5.04MB
```
- With reverse proxy
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 79.35ms 169.79ms 1.69s 92.55%
Req/Sec 4.27k 1.90k 19.61k 75.81%
Latency Distribution
50% 1.12ms
75% 105.66ms
90% 200.22ms
99% 814.59ms
409836 requests in 10.10s, 49.25MB read
Socket errors: connect 0, read 0, write 0, timeout 18
Requests/sec: 40581.61
Transfer/sec: 4.88MB
```
## Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs)
- Direct connection
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench
Running 10s test @ http://10.0.100.1/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 434.08us 539.35us 8.76ms 85.28%
Req/Sec 67.71k 6.31k 87.21k 71.20%
Latency Distribution
50% 153.00us
75% 646.00us
90% 1.18ms
99% 2.38ms
6739591 requests in 10.01s, 809.85MB read
Requests/sec: 673608.15
Transfer/sec: 80.94MB
```
- With `go-proxy` reverse proxy
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.23ms 0.96ms 11.43ms 72.09%
Req/Sec 17.48k 1.76k 21.48k 70.20%
Latency Distribution
50% 0.98ms
75% 1.76ms
90% 2.54ms
99% 4.24ms
1739079 requests in 10.01s, 208.97MB read
Requests/sec: 173779.44
Transfer/sec: 20.88MB
```
- With `traefik-v3`
```shell
root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench
Running 10s test @ http://127.0.0.1:8000/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 2.81ms 10.36ms 180.26ms 98.57%
Req/Sec 11.35k 1.74k 13.76k 85.54%
Latency Distribution
50% 1.59ms
75% 2.27ms
90% 3.17ms
99% 37.91ms
1125723 requests in 10.01s, 109.50MB read
Requests/sec: 112499.59
Transfer/sec: 10.94MB
```

View File

@@ -1,81 +0,0 @@
# Supported DNS Providers
<!-- TOC -->
- [Supported DNS Providers](#supported-dns-providers)
- [Cloudflare](#cloudflare)
- [CloudDNS](#clouddns)
- [DuckDNS](#duckdns)
- [OVHCloud](#ovhcloud)
- [Implement other DNS providers](#implement-other-dns-providers)
## Cloudflare
```yaml
autocert:
provider: cloudflare
options:
auth_token:
```
`auth_token` your zone API token
Follow [this guide](https://cloudkul.com/blog/automcatic-renew-and-generate-ssl-on-your-website-using-lego-client/) to create a new token with `Zone.DNS` read and edit permissions
## CloudDNS
```yaml
autocert:
provider: clouddns
options:
client_id:
email:
password:
```
## DuckDNS
```yaml
autocert:
provider: duckdns
options:
token:
```
Tested by [earvingad](https://github.com/earvingad)
## OVHCloud
```yaml
autocert:
provider: ovh
options:
api_endpoint:
application_key:
application_secret:
consumer_key:
oauth2_config:
client_id:
client_secret:
```
_Note, `application_key` and `oauth2_config` **CANNOT** be used together_
- `api_endpoint`: Endpoint URL, or one of
- `ovh-eu`,
- `ovh-ca`,
- `ovh-us`,
- `kimsufi-eu`,
- `kimsufi-ca`,
- `soyoustart-eu`,
- `soyoustart-ca`
- `application_secret`
- `application_key`
- `consumer_key`
- `oauth2_config`: Client ID and Client Secret
- `client_id`
- `client_secret`
## Implement other DNS providers
See [add_dns_provider.md](docs/add_dns_provider.md)

View File

@@ -1,296 +0,0 @@
# Docker compose guide
## Table of content
<!-- TOC -->
- [Docker compose guide](#docker-compose-guide)
- [Table of content](#table-of-content)
- [Additional setup](#additional-setup)
- [Labels](#labels)
- [Syntax](#syntax)
- [Fields](#fields)
- [Key-value mapping example](#key-value-mapping-example)
- [List example](#list-example)
- [Troubleshooting](#troubleshooting)
- [Docker compose examples](#docker-compose-examples)
- [Services URLs for above examples](#services-urls-for-above-examples)
## Additional setup
1. Enable HTTPs _(optional)_
Mount a folder to store obtained certs or to load existing cert
```yaml
services:
go-proxy:
...
volumes:
- ./certs:/app/certs
```
To use **autocert**, complete that section in `config.yml`, e.g.
```yaml
autocert:
email: john.doe@x.y.z # ACME Email
domains: # a list of domains for cert registration
- y.z
- *.y.z
provider: cloudflare
options:
auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
```
To use **existing certificate**, set path for cert and key in `config.yml`, e.g.
```yaml
autocert:
provider: local
cert_path: /app/certs/cert.crt
key_path: /app/certs/priv.key
```
2. Modify `compose.yml` to fit your needs
3. Run `docker compose up -d` to start the container
4. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
[🔼Back to top](#table-of-content)
## Labels
**Parts surrounded by `[]` are optional**
### Syntax
| Label | Description | Example | Default | Accepted values |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------- |
| `proxy.aliases` | comma separated aliases for subdomain and label matching | `gitlab,gitlab-reg,gitlab-ssh` | `container_name` | any |
| `proxy.exclude` | to be excluded from `go-proxy` | | false | boolean |
| `proxy.idle_timeout` | time for idle (no traffic) before put it into sleep **(http/s only)**<br> _**NOTE: idlewatcher will only be enabled containers that has non-empty `idle_timeout`**_ | `1h` | empty or `0` **(disabled)** | `number[unit]...`, e.g. `1m30s` |
| `proxy.wake_timeout` | time to wait for target site to be ready | | `30s` | `number[unit]...` |
| `proxy.stop_method` | method to stop after `idle_timeout` | | `stop` | `stop`, `pause`, `kill` |
| `proxy.stop_timeout` | time to wait for stop command | | `10s` | `number[unit]...` |
| `proxy.stop_signal` | signal sent to container for `stop` and `kill` methods | | docker's default | `SIGINT`, `SIGTERM`, `SIGHUP`, `SIGQUIT` and those without **SIG** prefix |
| `proxy.<alias>.<field>` | set field for specific alias | `proxy.gitlab-ssh.scheme` | N/A | N/A |
| `proxy.#<index>.<field>` | set field for specific alias at index (starting from **1**) | `proxy.#3.port` | N/A | N/A |
| `proxy.*.<field>` | set field for all aliases | `proxy.*.set_headers` | N/A | N/A |
| `proxy.?.middlewares.<middleware>[.<field>]` | enable and set field for specific middleware | **?** here means `<alias>` / `$<index>` / `*` <ul><li>`proxy.#1.middlewares.modify_request.set_headers`</li><li>`proxy.*.middlewares.modify_response.hide_headers`</li><li>`proxy.app1.middlewares.redirect_http`</li></ul> | N/A | Middleware specific<br>See [middlewares.md](middlewares.md) for more |
### Fields
| Field | Description | Default | Allowed Values / Syntax |
| --------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `scheme` | proxy protocol | <ul><li>`http` for numeric port</li><li>`tcp` for `x:y` port</li></ul> | `http`, `https`, `tcp`, `udp` |
| `host` | proxy host | <ul><li>Docker: docker client IP / hostname </li><li>File: `localhost`</li></ul> | IP address, hostname |
| `port` | proxy port **(http/s)** | first port returned from docker | number in range of `1 - 65535` |
| `port` | proxy port **(tcp/udp)** | `0:first_port` | `x:y` <br><ul><li>**x**: port for `go-proxy` to listen on.<br>**x** can be 0, which means listen on a random port</li><li>**y**: port or [_service name_](../src/common/constants.go#L55) of target container</li></ul> |
| `no_tls_verify` | whether skip tls verify **(https only)** | `false` | boolean |
| `path_patterns` | proxy path patterns **(http/s only)**<br> only requests that matched a pattern will be proxied | `/` **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of ([path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
[🔼Back to top](#table-of-content)
#### Key-value mapping example
Docker Compose
```yaml
services:
nginx:
...
labels:
proxy.nginx.middlewares.modify_request.set_headers: | # remember to add the '|'
X-Custom-Header1: value1, value2
X-Custom-Header2: value3, value4
```
File Provider
```yaml
service_a:
host: service_a.internal
middlewares:
modify_request:
set_headers:
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
[🔼Back to top](#table-of-content)
#### List example
Docker Compose
```yaml
services:
nginx:
...
labels:
proxy.nginx.path_patterns: | # remember to add the '|'
- GET /
- POST /auth
proxy.nginx.middlewares.modify_request.hide_headers: | # remember to add the '|'
- X-Custom-Header1
- X-Custom-Header2
```
Include file
```yaml
service_a:
host: service_a.internal
path_patterns:
- GET /
- POST /auth
middlewares:
modify_request:
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
```
[🔼Back to top](#table-of-content)
## Troubleshooting
- Container not showing up in proxies list
Please check that either `ports` or label `proxy.<alias>.port` is declared, e.g.
```yaml
services:
nginx-1: # Option 1
...
ports:
- 80
nginx-2: # Option 2
...
container_name: nginx-2
network_mode: host
labels:
proxy.nginx-2.port: 80
```
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
Explaination:
Docker network is usually `172.16.0.0/16`
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
[🔼Back to top](#table-of-content)
## Docker compose examples
More examples in [here](examples/)
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.#1.port=80
- proxy.#2.scheme=udp
- proxy.#2.port=20000:dns
- proxy.#3.port=3000
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
ports:
- 80
- 3000
- 53/udp
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
ports:
- 25565
labels:
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
ports:
- 8211/udp
- 27015/udp
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.#1.port=20002:8211
- proxy.#2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
volumes:
- nginx:/usr/share/nginx/html
ports:
- 80
labels:
proxy.idle_timeout: 1m
go-proxy:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock
go-proxy-frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
labels:
- proxy.aliases=gp
- proxy.gp.port=3000
depends_on:
- go-proxy
```
[🔼Back to top](#table-of-content)
### Services URLs for above examples
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:2000`: adguard dns (udp)
- `yourdomain.com:20001`: minecraft server
- `yourdomain.com:20002`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -1,40 +0,0 @@
## Docker Socket Proxy
For docker client on other machine, set this up, then add `name: tcp://<machine_ip>:2375` to `config.yml` under `docker` section
```yml
# compose.yml on remote machine (e.g. server1)
docker-proxy:
container_name: docker-proxy
image: tecnativa/docker-socket-proxy
privileged: true
environment:
- ALLOW_START=1
- ALLOW_STOP=1
- ALLOW_RESTARTS=1
- CONTAINERS=1
- EVENTS=1
- PING=1
- POST=1
- VERSION=1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
restart: always
ports:
- 2375:2375
# or more secure
- <machine_private_ip>:2375:2375
```
```yml
# config.yml on go-proxy machine
autocert:
... # your config
providers:
include:
...
docker:
...
server1: tcp://<machine_ip>:2375
```

View File

@@ -1,333 +0,0 @@
# Middlewares
## Table of content
<!-- TOC -->
- [Middlewares](#middlewares)
- [Table of content](#table-of-content)
- [Available middlewares](#available-middlewares)
- [Redirect http](#redirect-http)
- [Custom error pages](#custom-error-pages)
- [Modify request or response](#modify-request-or-response)
- [Set headers](#set-headers)
- [Add headers](#add-headers)
- [Hide headers](#hide-headers)
- [X-Forwarded-\* Headers](#x-forwarded--headers)
- [Add X-Forwarded-\*](#add-x-forwarded-)
- [Set X-Forwarded-\*](#set-x-forwarded-)
- [Forward Authorization header (experimental)](#forward-authorization-header-experimental)
- [Examples](#examples)
- [Authentik (untested, experimental)](#authentik-untested-experimental)
<!-- TOC -->
## Available middlewares
### Redirect http
Redirect http requests to https
```yaml
# docker labels
proxy.app1.middlewares.redirect_http:
# include file
app1:
middlewares:
redirect_http:
```
nginx equivalent:
```nginx
server {
listen 80;
server_name domain.tld;
return 301 https://$host$request_uri;
}
```
[🔼Back to top](#table-of-content)
### Custom error pages
To enable custom error pages, mount a folder containing your `html`, `js`, `css` files to `/app/error_pages` of _go-proxy_ container **(subfolders are ignored, please place all files in root directory)**
Any path under `error_pages` directory (e.g. `href` tag), should starts with `/$gperrorpage/`
Example:
```html
<html>
<head>
<title>404 Not Found</title>
<link type="text/css" rel="stylesheet" href="/$gperrorpage/style.css" />
</head>
<body>
...
</body>
</html>
```
Hot-reloading is **supported**, you can **edit**, **rename** or **delete** files **without restarting**. Changes will be reflected after page reload
Error page will be served if:
- route does not exist or domain does not match any of `match_domains`
or
- status code is not in range of 200 to 300, and
- content type is `text/html`, `application/xhtml+xml` or `text/plain`
Error page will be served:
- from file `<statusCode>.html` if exists
- otherwise from `404.html`
- if they don't exist, original response will be served
```yaml
# docker labels
proxy.app1.middlewares.custom_error_page:
# include file
app1:
middlewares:
custom_error_page:
```
nginx equivalent:
```nginx
location / {
try_files $uri $uri/ /error_pages/404.html =404;
}
```
[🔼Back to top](#table-of-content)
### Modify request or response
```yaml
# docker labels
proxy.app1.middlewares.modify_request.field:
proxy.app1.middlewares.modify_response.field:
# include file
app1:
middlewares:
modify_request:
field:
modify_response:
field:
```
#### Set headers
```yaml
# docker labels
proxy.app1.middlewares.modify_request.set_headers: |
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
# include file
app1:
middlewares:
modify_request:
set_headers:
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
nginx equivalent:
```nginx
location / {
add_header X-Custom-Header1 value1, value2;
add_header X-Custom-Header2 value3;
}
```
[🔼Back to top](#table-of-content)
#### Add headers
```yaml
# docker labels
proxy.app1.middlewares.modify_request.add_headers: |
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
# include file
app1:
middlewares:
modify_request:
add_headers:
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
nginx equivalent:
```nginx
location / {
more_set_headers "X-Custom-Header1: value1, value2";
more_set_headers "X-Custom-Header2: value3";
}
```
[🔼Back to top](#table-of-content)
#### Hide headers
```yaml
# docker labels
proxy.app1.middlewares.modify_request.hide_headers: |
- X-Custom-Header1
- X-Custom-Header2
# include file
app1:
middlewares:
modify_request:
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
```
nginx equivalent:
```nginx
location / {
more_clear_headers "X-Custom-Header1";
more_clear_headers "X-Custom-Header2";
}
```
### X-Forwarded-* Headers
#### Add X-Forwarded-*
Append `X-Forwarded-*` headers to existing headers
```yaml
# docker labels
proxy.app1.middlewares.modify_request.add_x_forwarded:
# include file
app1:
middlewares:
modify_request:
add_x_forwarded:
```
#### Set X-Forwarded-*
Replace existing `X-Forwarded-*` headers with `go-proxy` provided headers
```yaml
# docker labels
proxy.app1.middlewares.modify_request.set_x_forwarded:
# include file
app1:
middlewares:
modify_request:
set_x_forwarded:
```
[🔼Back to top](#table-of-content)
### Forward Authorization header (experimental)
Fields:
- `address`: authentication provider URL _(required)_
- `trust_forward_header`: whether to trust `X-Forwarded-*` headers from upstream proxies _(default: `false`)_
- `auth_response_headers`: list of headers to copy from auth response _(default: empty)_
- `add_auth_cookies_to_response`: list of cookies to add to response _(default: empty)_
```yaml
# docker labels
proxy.app1.middlewares.forward_auth.address: https://auth.example.com
proxy.app1.middlewares.forward_auth.trust_forward_header: true
proxy.app1.middlewares.forward_auth.auth_response_headers: |
- X-Auth-Token
- X-Auth-User
proxy.app1.middlewares.forward_auth.add_auth_cookies_to_response: |
- uid
- session_id
# include file
app1:
middlewares:
forward_authorization:
address: https://auth.example.com
trust_forward_header: true
auth_response_headers:
- X-Auth-Token
- X-Auth-User
add_auth_cookies_to_response:
- uid
- session_id
```
Traefik equivalent:
```yaml
# docker labels
traefik.http.middlewares.authentik.forwardauth.address: https://auth.example.com
traefik.http.middlewares.authentik.forwardauth.trustForwardHeader: true
traefik.http.middlewares.authentik.forwardauth.authResponseHeaders: X-Auth-Token, X-Auth-User
traefik.http.middlewares.authentik.forwardauth.addAuthCookiesToResponse: uid, session_id
# standalone
http:
middlewares:
forwardAuth:
address: https://auth.example.com
trustForwardHeader: true
authResponseHeaders:
- X-Auth-Token
- X-Auth-User
addAuthCookiesToResponse:
- uid
- session_id
```
[🔼Back to top](#table-of-content)
## Examples
### Authentik (untested, experimental)
```yaml
# docker compose
services:
...
server:
...
container_name: authentik
labels:
proxy.authentik.middlewares.redirect_http:
proxy.authentik.middlewares.set_x_forwarded:
proxy.authentik.middlewares.modify_request.add_headers: |
Strict-Transport-Security: "max-age=63072000" always
whoami:
image: containous/whoami
container_name: whoami
ports:
- 80
labels:
proxy.#1.middlewares.forward_auth.address: https://your_authentik_forward_address
proxy.#1.middlewares.forward_auth.trustForwardHeader: true
proxy.#1.middlewares.forward_auth.authResponseHeaders: |
- X-authentik-username
- X-authentik-groups
- X-authentik-email
- X-authentik-name
- X-authentik-uid
- X-authentik-jwt
- X-authentik-meta-jwks
- X-authentik-meta-outpost
- X-authentik-meta-provider
- X-authentik-meta-app
- X-authentik-meta-version
restart: unless-stopped
```
[🔼Back to top](#table-of-content)

View File

@@ -7,7 +7,7 @@ services:
limits:
memory: 256M
env_file: .env
image: docker.i.sh/danielszabo99/microbin:latest
image: danielszabo99/microbin:latest
ports:
- 8080
restart: unless-stopped

17
go.mod
View File

@@ -1,16 +1,18 @@
module github.com/yusing/go-proxy
go 1.23.1
go 1.23.2
require (
github.com/coder/websocket v1.8.12
github.com/docker/cli v27.3.1+incompatible
github.com/docker/docker v27.3.1+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.18.0
github.com/go-acme/lego/v4 v4.19.2
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.29.0
golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -45,14 +47,13 @@ require (
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.25.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

38
go.sum
View File

@@ -6,12 +6,15 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudflare/cloudflare-go v0.106.0 h1:q41gC5Wc1nfi0D1ZhSHokWcd9mGMbqC7RE7qiP+qE00=
github.com/cloudflare/cloudflare-go v0.106.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
@@ -26,8 +29,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-acme/lego/v4 v4.18.0 h1:2hH8KcdRBSb+p5o9VZIm61GAOXYALgILUCSs1Q+OYsk=
github.com/go-acme/lego/v4 v4.18.0/go.mod h1:Blkg3izvXpl3zxk7WKngIuwR2I/hvYVP3vRnvgBp7m8=
github.com/go-acme/lego/v4 v4.19.2 h1:Y8hrmMvWETdqzzkRly7m98xtPJJivWFsgWi8fcvZo+Y=
github.com/go-acme/lego/v4 v4.19.2/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -75,8 +78,9 @@ github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
@@ -111,8 +115,8 @@ go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
@@ -121,8 +125,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -134,25 +138,25 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE=
golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=

View File

@@ -24,26 +24,31 @@ func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc
func NewHandler(cfg *config.Config) http.Handler {
mux := NewServeMux()
mux.HandleFunc("GET", "/v1", v1.Index)
mux.HandleFunc("GET", "/v1/version", v1.GetVersion)
mux.HandleFunc("GET", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
mux.HandleFunc("HEAD", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
mux.HandleFunc("POST", "/v1/reload", wrap(cfg, v1.Reload))
mux.HandleFunc("GET", "/v1/list", wrap(cfg, v1.List))
mux.HandleFunc("GET", "/v1/list/{what}", wrap(cfg, v1.List))
mux.HandleFunc("GET", "/v1/file", v1.GetFileContent)
mux.HandleFunc("GET", "/v1/file/{filename}", v1.GetFileContent)
mux.HandleFunc("POST", "/v1/file/{filename}", v1.SetFileContent)
mux.HandleFunc("PUT", "/v1/file/{filename}", v1.SetFileContent)
mux.HandleFunc("GET", "/v1/file/{filename...}", v1.GetFileContent)
mux.HandleFunc("POST", "/v1/file/{filename...}", v1.SetFileContent)
mux.HandleFunc("PUT", "/v1/file/{filename...}", v1.SetFileContent)
mux.HandleFunc("GET", "/v1/stats", wrap(cfg, v1.Stats))
mux.HandleFunc("GET", "/v1/stats/ws", wrap(cfg, v1.StatsWS))
mux.HandleFunc("GET", "/v1/error_page", error_page.GetHandleFunc())
return mux
}
// allow only requests to API server with host matching common.APIHTTPAddr
func checkHost(f http.HandlerFunc) http.HandlerFunc {
if common.IsDebug {
return f
}
return func(w http.ResponseWriter, r *http.Request) {
if r.Host != common.APIHTTPAddr {
Logger.Warnf("invalid request to API server with host: %s, expect %s", r.Host, common.APIHTTPAddr)
w.WriteHeader(http.StatusNotFound)
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("invalid request"))
return
}

View File

@@ -25,10 +25,10 @@ func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.ErrNotFound("target", target), http.StatusNotFound)
return
case route.Type() == R.RouteTypeReverseProxy:
ok = U.IsSiteHealthy(route.URL().String())
ok = IsSiteHealthy(route.URL().String())
case route.Type() == R.RouteTypeStream:
entry := route.Entry()
ok = U.IsStreamHealthy(
ok = IsStreamHealthy(
strings.Split(entry.Scheme, ":")[1], // target scheme
fmt.Sprintf("%s:%v", entry.Host, strings.Split(entry.Port, ":")[1]),
)

View File

@@ -5,6 +5,7 @@ import (
"net/http"
"os"
"path"
"strings"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
@@ -41,7 +42,7 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
var validateErr E.NestedError
if filename == common.ConfigFileName {
validateErr = config.Validate(content)
} else {
} else if !strings.HasPrefix(filename, path.Base(common.MiddlewareComposeBasePath)) {
validateErr = provider.Validate(content)
}

View File

@@ -1,21 +1,22 @@
package utils
package v1
import (
"net"
"net/http"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
)
func IsSiteHealthy(url string) bool {
// try HEAD first
// if HEAD is not allowed, try GET
resp, err := httpClient.Head(url)
resp, err := U.Head(url)
if resp != nil {
resp.Body.Close()
}
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
_, err = httpClient.Get(url)
_, err = U.Get(url)
}
if resp != nil {
resp.Body.Close()

View File

@@ -1,26 +1,44 @@
package v1
import (
"encoding/json"
"net/http"
"os"
"strings"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/utils"
)
const (
ListRoutes = "routes"
ListConfigFiles = "config_files"
ListMiddlewares = "middlewares"
ListMiddlewareTrace = "middleware_trace"
ListMatchDomains = "match_domains"
ListHomepageConfig = "homepage_config"
)
func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
what := r.PathValue("what")
if what == "" {
what = "routes"
what = ListRoutes
}
switch what {
case "routes":
case ListRoutes:
listRoutes(cfg, w, r)
case "config_files":
case ListConfigFiles:
listConfigFiles(w, r)
case ListMiddlewares:
listMiddlewares(w, r)
case ListMiddlewareTrace:
listMiddlewareTrace(w, r)
case ListMatchDomains:
listMatchDomains(cfg, w, r)
case ListHomepageConfig:
listHomepageConfig(cfg, w, r)
default:
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
}
@@ -37,25 +55,33 @@ func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
}
}
if err := U.RespondJson(w, routes); err != nil {
U.HandleErr(w, r, err)
}
U.HandleErr(w, r, U.RespondJson(w, routes))
}
func listConfigFiles(w http.ResponseWriter, r *http.Request) {
files, err := os.ReadDir(common.ConfigBasePath)
files, err := utils.ListFiles(common.ConfigBasePath, 1)
if err != nil {
U.HandleErr(w, r, err)
return
}
filenames := make([]string, len(files))
for i, f := range files {
filenames[i] = f.Name()
for i := range files {
files[i] = strings.TrimPrefix(files[i], common.ConfigBasePath+"/")
}
resp, err := json.Marshal(filenames)
if err != nil {
U.HandleErr(w, r, err)
return
}
w.Write(resp)
U.HandleErr(w, r, U.RespondJson(w, files))
}
func listMiddlewareTrace(w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.RespondJson(w, middleware.GetAllTrace()))
}
func listMiddlewares(w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.RespondJson(w, middleware.All()))
}
func listMatchDomains(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.RespondJson(w, cfg.Value().MatchDomains))
}
func listHomepageConfig(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.HandleErr(w, r, U.RespondJson(w, cfg.HomepageConfig()))
}

View File

@@ -0,0 +1,69 @@
package query
import (
"encoding/json"
"fmt"
"io"
"net/http"
v1 "github.com/yusing/go-proxy/internal/api/v1"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/middleware"
)
func ReloadServer() E.NestedError {
resp, err := U.Post(fmt.Sprintf("%s/v1/reload", common.APIHTTPURL), "", nil)
if err != nil {
return E.From(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
failure := E.Failure("server reload").Extraf("status code: %v", resp.StatusCode)
b, err := io.ReadAll(resp.Body)
if err != nil {
return failure.Extraf("unable to read response body: %s", err)
}
reloadErr, ok := E.FromJSON(b)
if ok {
return E.Join("reload success, but server returned error", reloadErr)
}
return failure.Extraf("unable to read response body")
}
return nil
}
func ListRoutes() (map[string]map[string]any, E.NestedError) {
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, v1.ListRoutes))
if err != nil {
return nil, E.From(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, E.Failure("list routes").Extraf("status code: %v", resp.StatusCode)
}
var routes map[string]map[string]any
err = json.NewDecoder(resp.Body).Decode(&routes)
if err != nil {
return nil, E.From(err)
}
return routes, nil
}
func ListMiddlewareTraces() (middleware.Traces, E.NestedError) {
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, v1.ListMiddlewareTrace))
if err != nil {
return nil, E.From(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, E.Failure("list middleware trace").Extraf("status code: %v", resp.StatusCode)
}
var traces middleware.Traces
err = json.NewDecoder(resp.Body).Decode(&traces)
if err != nil {
return nil, E.From(err)
}
return traces, nil
}

View File

@@ -1,20 +1,67 @@
package v1
import (
"context"
"net/http"
"time"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/server"
"github.com/yusing/go-proxy/internal/utils"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
stats := map[string]any{
U.HandleErr(w, r, U.RespondJson(w, getStats(cfg)))
}
func StatsWS(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
originPats := make([]string, len(cfg.Value().MatchDomains)+len(localAddresses))
if len(originPats) == 0 {
U.Logger.Warnf("no match domains configured, accepting websocket request from all origins")
originPats = []string{"*"}
} else {
for i, domain := range cfg.Value().MatchDomains {
originPats[i] = "*." + domain
}
originPats = append(originPats, localAddresses...)
}
if common.IsDebug {
originPats = []string{"*"}
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
OriginPatterns: originPats,
})
if err != nil {
U.Logger.Errorf("/stats/ws failed to upgrade websocket: %s", err)
return
}
defer conn.CloseNow()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
stats := getStats(cfg)
if err := wsjson.Write(ctx, conn, stats); err != nil {
U.Logger.Errorf("/stats/ws failed to write JSON: %s", err)
return
}
}
}
func getStats(cfg *config.Config) map[string]any {
return map[string]any{
"proxies": cfg.Statistics(),
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
}
if err := U.RespondJson(w, stats); err != nil {
U.HandleErr(w, r, err)
}
}

View File

@@ -12,6 +12,9 @@ import (
var Logger = logrus.WithField("module", "api")
func HandleErr(w http.ResponseWriter, r *http.Request, origErr error, code ...int) {
if origErr == nil {
return
}
err := E.From(origErr).Subjectf("%s %s", r.Method, r.URL)
Logger.Error(err)
if len(code) > 0 {

View File

@@ -8,7 +8,7 @@ import (
"github.com/yusing/go-proxy/internal/common"
)
var httpClient = &http.Client{
var HTTPClient = &http.Client{
Timeout: common.ConnectionTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
@@ -21,3 +21,7 @@ var httpClient = &http.Client{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
var Get = HTTPClient.Get
var Post = HTTPClient.Post
var Head = HTTPClient.Head

View File

@@ -1,31 +0,0 @@
package utils
import (
"fmt"
"io"
"net/http"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
)
func ReloadServer() E.NestedError {
resp, err := httpClient.Post(fmt.Sprintf("%s/v1/reload", common.APIHTTPURL), "", nil)
if err != nil {
return E.From(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
failure := E.Failure("server reload").Extraf("status code: %v", resp.StatusCode)
b, err := io.ReadAll(resp.Body)
if err != nil {
return failure.Extraf("unable to read response body: %s", err)
}
reloadErr, ok := E.FromJSON(b)
if ok {
return E.Join("reload success, but server returned error", reloadErr)
}
return failure.Extraf("unable to read response body")
}
return nil
}

View File

@@ -0,0 +1,11 @@
package v1
import (
"net/http"
"github.com/yusing/go-proxy/pkg"
)
func GetVersion(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(pkg.GetVersion()))
}

View File

@@ -8,12 +8,13 @@ import (
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
"github.com/yusing/go-proxy/internal/types"
)
type Config M.AutoCertConfig
type Config types.AutoCertConfig
func NewConfig(cfg *M.AutoCertConfig) *Config {
func NewConfig(cfg *types.AutoCertConfig) *Config {
if cfg.CertPath == "" {
cfg.CertPath = CertFileDefault
}

View File

@@ -15,7 +15,8 @@ import (
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
)
@@ -29,7 +30,7 @@ type Provider struct {
certExpiries CertExpiries
}
type ProviderGenerator func(M.AutocertProviderOpt) (challenge.Provider, E.NestedError)
type ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.NestedError)
type CertExpiries map[string]time.Time
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
@@ -280,7 +281,7 @@ func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt M.AutocertProviderOpt) (challenge.Provider, E.NestedError) {
return func(opt types.AutocertProviderOpt) (challenge.Provider, E.NestedError) {
cfg := defaultCfg()
err := U.Deserialize(opt, cfg)
if err.HasError() {

View File

@@ -45,6 +45,6 @@ oauth2_config:
testYaml = testYaml[1:] // remove first \n
opt := make(map[string]any)
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
ExpectTrue(t, U.Deserialize(opt, cfg).NoError())
ExpectNoError(t, U.Deserialize(opt, cfg).Error())
ExpectDeepEqual(t, cfg, cfgExpected)
}

View File

@@ -2,9 +2,9 @@ package common
import (
"flag"
"fmt"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/internal/error"
)
type Args struct {
@@ -17,9 +17,11 @@ const (
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandListIcons = "ls-icons"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandDebugListProviders = "debug-ls-providers"
CommandDebugListMTrace = "debug-ls-mtrace"
)
var ValidCommands = []string{
@@ -28,26 +30,28 @@ var ValidCommands = []string{
CommandValidate,
CommandListConfigs,
CommandListRoutes,
CommandListIcons,
CommandReload,
CommandDebugListEntries,
CommandDebugListProviders,
CommandDebugListMTrace,
}
func GetArgs() Args {
var args Args
flag.Parse()
args.Command = flag.Arg(0)
if err := validateArg(args.Command); err.HasError() {
if err := validateArg(args.Command); err != nil {
logrus.Fatal(err)
}
return args
}
func validateArg(arg string) E.NestedError {
func validateArg(arg string) error {
for _, v := range ValidCommands {
if arg == v {
return nil
}
}
return E.Invalid("argument", arg)
return fmt.Errorf("invalid command: %s", arg)
}

View File

@@ -17,6 +17,8 @@ const (
ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml"
ConfigPath = ConfigBasePath + "/" + ConfigFileName
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
)
const (
@@ -39,6 +41,7 @@ var (
ConfigBasePath,
SchemaBasePath,
ErrorPagesBasePath,
MiddlewareComposeBasePath,
}
)

View File

@@ -2,16 +2,19 @@ package common
import (
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"github.com/sirupsen/logrus"
U "github.com/yusing/go-proxy/internal/utils"
)
var (
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION", false)
IsDebug = GetEnvBool("GOPROXY_DEBUG", false)
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION", true)
IsTest = GetEnvBool("GOPROXY_TEST", false) || strings.HasSuffix(os.Args[0], ".test")
IsDebug = GetEnvBool("GOPROXY_DEBUG", IsTest)
ProxyHTTPAddr,
ProxyHTTPHost,
@@ -34,7 +37,11 @@ func GetEnvBool(key string, defaultValue bool) bool {
if !ok || value == "" {
return defaultValue
}
return U.ParseBool(value)
b, err := strconv.ParseBool(value)
if err != nil {
log.Fatalf("Invalid boolean value: %s", value)
}
return b
}
func GetEnv(key, defaultValue string) string {

View File

@@ -8,9 +8,10 @@ import (
"github.com/yusing/go-proxy/internal/autocert"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
PR "github.com/yusing/go-proxy/internal/proxy/provider"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
W "github.com/yusing/go-proxy/internal/watcher"
@@ -19,7 +20,7 @@ import (
)
type Config struct {
value *M.Config
value *types.Config
proxyProviders F.Map[string, *PR.Provider]
autocertProvider *autocert.Provider
@@ -42,7 +43,7 @@ func Load() E.NestedError {
return nil
}
instance = &Config{
value: M.DefaultConfig(),
value: types.DefaultConfig(),
proxyProviders: F.NewMapOf[string, *PR.Provider](),
l: logrus.WithField("module", "config"),
watcher: W.NewConfigFileWatcher(common.ConfigFileName),
@@ -62,7 +63,7 @@ func MatchDomains() []string {
return instance.value.MatchDomains
}
func (cfg *Config) Value() M.Config {
func (cfg *Config) Value() types.Config {
if cfg == nil {
logrus.Panic("config has not been loaded, please check if there is any errors")
}
@@ -158,7 +159,7 @@ func (cfg *Config) load() (res E.NestedError) {
}
}
model := M.DefaultConfig()
model := types.DefaultConfig()
if err := E.From(yaml.Unmarshal(data, model)); err.HasError() {
b.Add(E.FailWith("parse config", err))
logrus.Fatal(b.Build())
@@ -173,7 +174,7 @@ func (cfg *Config) load() (res E.NestedError) {
return
}
func (cfg *Config) initAutoCert(autocertCfg *M.AutoCertConfig) (err E.NestedError) {
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.NestedError) {
if cfg.autocertProvider != nil {
return
}
@@ -188,7 +189,7 @@ func (cfg *Config) initAutoCert(autocertCfg *M.AutoCertConfig) (err E.NestedErro
return
}
func (cfg *Config) loadProviders(providers *M.ProxyProviders) (res E.NestedError) {
func (cfg *Config) loadProviders(providers *types.ProxyProviders) (res E.NestedError) {
cfg.l.Debug("loading providers")
defer cfg.l.Debug("loaded providers")

View File

@@ -1,15 +1,20 @@
package config
import (
M "github.com/yusing/go-proxy/internal/models"
"fmt"
"strings"
"github.com/yusing/go-proxy/internal/common"
H "github.com/yusing/go-proxy/internal/homepage"
PR "github.com/yusing/go-proxy/internal/proxy/provider"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
func (cfg *Config) DumpEntries() map[string]*M.RawEntry {
entries := make(map[string]*M.RawEntry)
func (cfg *Config) DumpEntries() map[string]*types.RawEntry {
entries := make(map[string]*types.RawEntry)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
entries[alias] = r.Entry()
})
@@ -24,9 +29,80 @@ func (cfg *Config) DumpProviders() map[string]*PR.Provider {
return entries
}
func (cfg *Config) HomepageConfig() H.HomePageConfig {
var proto, port string
domains := cfg.value.MatchDomains
cert, _ := cfg.autocertProvider.GetCert(nil)
if cert != nil {
proto = "https"
port = common.ProxyHTTPSPort
} else {
proto = "http"
port = common.ProxyHTTPPort
}
hpCfg := H.NewHomePageConfig()
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
if !r.Started() {
return
}
entry := r.Entry()
if entry.Homepage == nil {
entry.Homepage = &H.HomePageItem{
Show: r.Entry().IsExplicit || !p.IsExplicitOnly(),
}
}
item := entry.Homepage
if !item.Show && !item.IsEmpty() {
item.Show = true
}
if !item.Show || r.Type() != R.RouteTypeReverseProxy {
return
}
if item.Name == "" {
item.Name = U.Title(
strings.ReplaceAll(
strings.ReplaceAll(alias, "-", " "),
"_", " ",
),
)
}
if p.GetType() == PR.ProviderTypeDocker {
if item.Category == "" {
item.Category = "Docker"
}
item.SourceType = string(PR.ProviderTypeDocker)
} else if p.GetType() == PR.ProviderTypeFile {
if item.Category == "" {
item.Category = "Others"
}
item.SourceType = string(PR.ProviderTypeFile)
}
if item.URL == "" {
if len(domains) > 0 {
item.URL = fmt.Sprintf("%s://%s.%s:%s", proto, strings.ToLower(alias), domains[0], port)
}
}
item.AltURL = r.URL().String()
hpCfg.Add(item)
})
return hpCfg
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
if !r.Started() {
return
}
obj, err := U.Serialize(r)
if err.HasError() {
cfg.l.Error(err)
@@ -34,6 +110,8 @@ func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
}
obj["provider"] = p.GetName()
obj["type"] = string(r.Type())
obj["started"] = r.Started()
obj["raw"] = r.Entry()
routes[alias] = obj
})
return routes
@@ -42,27 +120,17 @@ func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
func (cfg *Config) Statistics() map[string]any {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]any)
providerStats := make(map[string]PR.ProviderStats)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
s, ok := providerStats[p.GetName()]
if !ok {
s = make(map[string]int)
}
stats := s.(map[string]int)
switch r.Type() {
case R.RouteTypeStream:
stats["num_streams"]++
nTotalStreams++
case R.RouteTypeReverseProxy:
stats["num_reverse_proxies"]++
nTotalRPs++
default:
panic("bug: should not reach here")
}
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
providerStats[name] = p.Statistics()
})
for _, stats := range providerStats {
nTotalRPs += stats.NumRPs
nTotalStreams += stats.NumStreams
}
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,

View File

@@ -20,6 +20,7 @@ func FromDocker(c *types.Container, dockerHost string) (res Container) {
res.ProxyProperties = &ProxyProperties{
DockerHost: dockerHost,
ContainerName: res.getName(),
ContainerID: c.ID,
ImageName: res.getImageName(),
PublicPortMapping: res.getPublicPortMapping(),
PrivatePortMapping: res.getPrivatePortMapping(),
@@ -27,6 +28,7 @@ func FromDocker(c *types.Container, dockerHost string) (res Container) {
Aliases: res.getAliases(),
IsExcluded: U.ParseBool(res.getDeleteLabel(LabelExclude)),
IsExplicit: isExplicit,
IsDatabase: res.isDatabase(),
IdleTimeout: res.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: res.getDeleteLabel(LabelWakeTimeout),
StopMethod: res.getDeleteLabel(LabelStopMethod),
@@ -107,3 +109,35 @@ func (c Container) getPrivatePortMapping() PortMapping {
}
return res
}
var databaseMPs = map[string]struct{}{
"/var/lib/postgresql/data": {},
"/var/lib/mysql": {},
"/var/lib/mongodb": {},
"/var/lib/mariadb": {},
"/var/lib/memcached": {},
"/var/lib/rabbitmq": {},
}
var databasePrivPorts = map[uint16]struct{}{
5432: {}, // postgres
3306: {}, // mysql, mariadb
6379: {}, // redis
11211: {}, // memcached
27017: {}, // mongodb
}
func (c Container) isDatabase() bool {
for _, m := range c.Container.Mounts {
if _, ok := databaseMPs[m.Destination]; ok {
return true
}
}
for _, v := range c.Ports {
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
return true
}
}
return false
}

View File

@@ -1,32 +0,0 @@
package docker
type (
HomePageConfig struct{ m map[string]HomePageCategory }
HomePageCategory []HomePageItem
HomePageItem struct {
Name string
Icon string
Category string
Description string
WidgetConfig map[string]any
}
)
func NewHomePageConfig() *HomePageConfig {
return &HomePageConfig{m: make(map[string]HomePageCategory)}
}
func NewHomePageItem() *HomePageItem {
return &HomePageItem{}
}
func (c *HomePageConfig) Clear() {
c.m = make(map[string]HomePageCategory)
}
func (c *HomePageConfig) Add(item HomePageItem) {
c.m[item.Category] = HomePageCategory{item}
}
const NSHomePage = "homepage"

View File

@@ -66,22 +66,23 @@
<body>
<script>
window.onload = async function () {
let result = await fetch(window.location.href, {
let resp = await fetch(window.location.href, {
headers: {
{{ range $key, $value := .RequestHeaders }}
'{{ $key }}' : {{ $value }}
{{ end }}
"{{.CheckRedirectHeader}}": "1",
},
}).then((resp) => resp.text())
.catch((err) => {
document.getElementById("message").innerText = err;
});
if (result) {
document.documentElement.innerHTML = result
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText =
await resp.text();
document
.getElementById("spinner")
.classList.replace("spinner", "error");
}
};
</script>
<div class="{{.SpinnerClass}}"></div>
<div class="message">{{.Message}}</div>
<div id="spinner" class="spinner"></div>
<div id="message" class="message">{{.Message}}</div>
</body>
</html>

View File

@@ -4,84 +4,35 @@ import (
"bytes"
_ "embed"
"fmt"
"io"
"net/http"
"strings"
"text/template"
)
type templateData struct {
Title string
Message string
RequestHeaders http.Header
SpinnerClass string
CheckRedirectHeader string
Title string
Message string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
const (
htmlContentType = "text/html; charset=utf-8"
const headerCheckRedirect = "X-GoProxy-Check-Redirect"
errPrefix = "\u1000"
headerGoProxyTargetURL = "X-GoProxy-Target"
headerContentType = "Content-Type"
spinnerClassSpinner = "spinner"
spinnerClassErrorSign = "error"
)
func (w *watcher) makeSuccResp(redirectURL string, resp *http.Response) (*http.Response, error) {
h := make(http.Header)
h.Set("Location", redirectURL)
h.Set("Content-Length", "0")
h.Set(headerContentType, htmlContentType)
return &http.Response{
StatusCode: http.StatusTemporaryRedirect,
Header: h,
Body: http.NoBody,
TLS: resp.TLS,
}, nil
}
func (w *watcher) makeErrResp(errFmt string, args ...any) (*http.Response, error) {
return w.makeResp(errPrefix+errFmt, args...)
}
func (w *watcher) makeResp(format string, args ...any) (*http.Response, error) {
func (w *watcher) makeRespBody(format string, args ...any) []byte {
msg := fmt.Sprintf(format, args...)
data := new(templateData)
data.CheckRedirectHeader = headerCheckRedirect
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
data.Message = strings.ReplaceAll(data.Message, " ", "&ensp;")
data.RequestHeaders = make(http.Header)
data.RequestHeaders.Add(headerGoProxyTargetURL, "window.location.href")
if strings.HasPrefix(data.Message, errPrefix) {
data.Message = strings.TrimLeft(data.Message, errPrefix)
data.SpinnerClass = spinnerClassErrorSign
} else {
data.SpinnerClass = spinnerClassSpinner
}
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen
if err != nil { // should never happen in production
panic(err)
}
return &http.Response{
StatusCode: http.StatusAccepted,
Header: http.Header{
headerContentType: {htmlContentType},
"Cache-Control": {
"no-cache",
"no-store",
"must-revalidate",
},
},
Body: io.NopCloser(buf),
ContentLength: int64(buf.Len()),
}, nil
return buf.Bytes()
}

View File

@@ -1,83 +0,0 @@
package idlewatcher
import (
"context"
"net/http"
)
type (
roundTripper struct {
patched roundTripFunc
}
roundTripFunc func(*http.Request) (*http.Response, error)
)
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return rt.patched(req)
}
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
// wake the container
select {
case w.wakeCh <- struct{}{}:
default:
}
// target site is ready, passthrough
if w.ready.Load() {
return origRoundTrip(req)
}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
return w.makeResp(
"%s is starting... Please wait",
w.ContainerName,
)
}
w.l.Debug("serving event")
// stream request
rtDone := make(chan *http.Response, 1)
ctx, cancel := context.WithTimeout(req.Context(), w.WakeTimeout)
defer cancel()
// loop original round trip until success in a goroutine
go func() {
for {
select {
case <-ctx.Done():
return
case <-w.ctx.Done():
return
default:
resp, err := origRoundTrip(req)
if err == nil {
w.ready.Store(true)
rtDone <- resp
return
}
}
}
}()
for {
select {
case resp := <-rtDone:
return w.makeSuccResp(targetUrl, resp)
case err := <-w.wakeDone:
if err != nil {
return w.makeErrResp("error waking up %s\n%s", w.ContainerName, err.Error())
}
case <-ctx.Done():
if ctx.Err() == context.DeadlineExceeded {
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)
}
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
case <-w.ctx.Done():
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err().Error())
}
}
}

View File

@@ -0,0 +1,106 @@
package idlewatcher
import (
"context"
"crypto/tls"
"net/http"
"time"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
type Waker struct {
*watcher
client *http.Client
rp *gphttp.ReverseProxy
}
func NewWaker(w *watcher, rp *gphttp.ReverseProxy) *Waker {
tr := &http.Transport{}
if w.NoTLSVerify {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
return &Waker{
watcher: w,
client: &http.Client{
Timeout: 1 * time.Second,
Transport: tr,
},
rp: rp,
}
}
func (w *Waker) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
w.wake(w.rp.ServeHTTP, rw, r)
}
func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Request) {
// pass through if container is ready
if w.ready.Load() {
next(rw, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), w.WakeTimeout)
defer cancel()
isCheckRedirect := r.Header.Get(headerCheckRedirect) != ""
if !isCheckRedirect {
// Send a loading response to the client
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Write(w.makeRespBody("%s waking up...", w.ContainerName))
return
}
// wake the container and reset idle timer
// also wait for another wake request
w.wakeCh <- struct{}{}
if <-w.wakeDone != nil {
http.Error(rw, "Error sending wake request", http.StatusInternalServerError)
return
}
// maybe another request came in while we were waiting for the wake
if w.ready.Load() {
if isCheckRedirect {
rw.WriteHeader(http.StatusOK)
} else {
next(rw, r)
}
return
}
for {
select {
case <-ctx.Done():
http.Error(rw, "Waking timed out", http.StatusGatewayTimeout)
return
default:
}
wakeReq, err := http.NewRequestWithContext(
ctx,
http.MethodHead,
w.URL.String(),
nil,
)
if err != nil {
w.l.Errorf("new request err to %s: %s", r.URL, err)
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
// we don't care about the response
_, err = w.client.Do(wakeReq)
if err == nil {
w.ready.Store(true)
rw.WriteHeader(http.StatusOK)
return
}
// retry until the container is ready or timeout
time.Sleep(100 * time.Millisecond)
}
}

View File

@@ -2,7 +2,6 @@ package idlewatcher
import (
"context"
"net/http"
"sync"
"sync/atomic"
"time"
@@ -63,7 +62,9 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
if w, ok := watcherMap[entry.ContainerName]; ok {
key := entry.ContainerID
if w, ok := watcherMap[key]; ok {
w.refCount.Add(1)
w.ReverseProxyEntry = entry
return w, nil
@@ -85,7 +86,7 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
w.refCount.Add(1)
w.stopByMethod = w.getStopCallback()
watcherMap[w.ContainerName] = w
watcherMap[key] = w
go func() {
newWatcherCh <- w
@@ -94,10 +95,8 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
return w, nil
}
func Unregister(containerName string) {
if w, ok := watcherMap[containerName]; ok {
w.refCount.Add(-1)
}
func (w *watcher) Unregister() {
w.refCount.Add(-1)
}
func Start() {
@@ -118,7 +117,7 @@ func Start() {
w.refCount.Wait() // wait for 0 ref count
w.client.Close()
delete(watcherMap, w.ContainerName)
delete(watcherMap, w.ContainerID)
w.l.Debug("unregistered")
mainLoopWg.Done()
}()
@@ -131,36 +130,30 @@ func Stop() {
mainLoopWg.Wait()
}
func (w *watcher) PatchRoundTripper(rtp http.RoundTripper) roundTripper {
return roundTripper{patched: func(r *http.Request) (*http.Response, error) {
return w.roundTrip(rtp.RoundTrip, r)
}}
}
func (w *watcher) containerStop() error {
return w.client.ContainerStop(w.ctx, w.ContainerName, container.StopOptions{
return w.client.ContainerStop(w.ctx, w.ContainerID, container.StopOptions{
Signal: string(w.StopSignal),
Timeout: &w.StopTimeout})
}
func (w *watcher) containerPause() error {
return w.client.ContainerPause(w.ctx, w.ContainerName)
return w.client.ContainerPause(w.ctx, w.ContainerID)
}
func (w *watcher) containerKill() error {
return w.client.ContainerKill(w.ctx, w.ContainerName, string(w.StopSignal))
return w.client.ContainerKill(w.ctx, w.ContainerID, string(w.StopSignal))
}
func (w *watcher) containerUnpause() error {
return w.client.ContainerUnpause(w.ctx, w.ContainerName)
return w.client.ContainerUnpause(w.ctx, w.ContainerID)
}
func (w *watcher) containerStart() error {
return w.client.ContainerStart(w.ctx, w.ContainerName, container.StartOptions{})
return w.client.ContainerStart(w.ctx, w.ContainerID, container.StartOptions{})
}
func (w *watcher) containerStatus() (string, E.NestedError) {
json, err := w.client.ContainerInspect(w.ctx, w.ContainerName)
json, err := w.client.ContainerInspect(w.ctx, w.ContainerID)
if err != nil {
return "", E.FailWith("inspect container", err)
}
@@ -168,6 +161,10 @@ func (w *watcher) containerStatus() (string, E.NestedError) {
}
func (w *watcher) wakeIfStopped() E.NestedError {
if w.ready.Load() || w.ContainerRunning {
return nil
}
status, err := w.containerStatus()
if err.HasError() {
@@ -219,7 +216,7 @@ func (w *watcher) watchUntilCancel() {
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
Filters: W.NewDockerFilter(
W.DockerFilterContainer,
W.DockerrFilterContainerName(w.ContainerName),
W.DockerrFilterContainer(w.ContainerID),
W.DockerFilterStart,
W.DockerFilterStop,
W.DockerFilterDie,
@@ -264,13 +261,10 @@ func (w *watcher) watchUntilCancel() {
w.l.Debug("wake signal received")
ticker.Reset(w.IdleTimeout)
err := w.wakeIfStopped()
if err != nil && err.IsNot(context.Canceled) {
if err != nil {
w.l.Error(E.FailWith("wake", err))
}
select {
case w.wakeDone <- err: // this is passed to roundtrip
default:
}
w.wakeDone <- err
}
}
}

View File

@@ -6,7 +6,6 @@ import (
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
/*
@@ -23,8 +22,6 @@ type (
Value any
}
NestedLabelMap map[string]U.SerializedObject
ValueParser func(string) (any, E.NestedError)
ValueParserMap map[string]ValueParser
)
func (l *Label) String() string {
@@ -61,7 +58,14 @@ func ApplyLabel[T any](obj *T, l *Label) E.NestedError {
}
dst, ok := field.Interface().(NestedLabelMap)
if !ok {
return E.Invalid("type", field.Type())
if field.Kind() == reflect.Ptr {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
} else {
field = field.Addr()
}
return U.Deserialize(U.SerializedObject{nestedLabel.Namespace: nestedLabel.Value}, field.Interface())
}
if dst == nil {
field.Set(reflect.MakeMap(reflect.TypeFor[NestedLabelMap]()))
@@ -107,45 +111,5 @@ func ParseLabel(label string, value string) (*Label, E.NestedError) {
l.Value = nestedLabel
}
// find if namespace has value parser
pm, ok := valueParserMap.Load(U.ToLowerNoSnake(l.Namespace))
if !ok {
return l, nil
}
// find if attribute has value parser
p, ok := pm[U.ToLowerNoSnake(l.Attribute)]
if !ok {
return l, nil
}
// try to parse value
v, err := p(value)
if err.HasError() {
return nil, err.Subject(label)
}
l.Value = v
return l, nil
}
func RegisterNamespace(namespace string, pm ValueParserMap) {
pmCleaned := make(ValueParserMap, len(pm))
for k, v := range pm {
pmCleaned[U.ToLowerNoSnake(k)] = v
}
valueParserMap.Store(U.ToLowerNoSnake(namespace), pmCleaned)
}
func GetRegisteredNamespaces() map[string][]string {
r := make(map[string][]string)
valueParserMap.RangeAll(func(ns string, vpm ValueParserMap) {
r[ns] = make([]string, 0, len(vpm))
for attr := range vpm {
r[ns] = append(r[ns], attr)
}
})
return r
}
// namespace:target.attribute -> func(string) (any, error)
var valueParserMap = F.NewMapOf[string, ValueParserMap]()

View File

@@ -1,78 +0,0 @@
package docker
import (
"strings"
E "github.com/yusing/go-proxy/internal/error"
"gopkg.in/yaml.v3"
)
const (
NSProxy = "proxy"
ProxyAttributePathPatterns = "path_patterns"
ProxyAttributeNoTLSVerify = "no_tls_verify"
ProxyAttributeMiddlewares = "middlewares"
)
var _ = func() int {
RegisterNamespace(NSProxy, ValueParserMap{
ProxyAttributePathPatterns: YamlStringListParser,
ProxyAttributeNoTLSVerify: BoolParser,
})
return 0
}()
func YamlStringListParser(value string) (any, E.NestedError) {
/*
- foo
- bar
- baz
*/
value = strings.TrimSpace(value)
if value == "" {
return []string{}, nil
}
var data []string
err := E.From(yaml.Unmarshal([]byte(value), &data))
return data, err
}
func YamlLikeMappingParser(allowDuplicate bool) func(string) (any, E.NestedError) {
return func(value string) (any, E.NestedError) {
/*
foo: bar
boo: baz
*/
value = strings.TrimSpace(value)
lines := strings.Split(value, "\n")
h := make(map[string]string)
for _, line := range lines {
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return nil, E.Invalid("syntax", line).With("too many colons")
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
if existing, ok := h[key]; ok {
if !allowDuplicate {
return nil, E.Duplicated("key", key)
}
h[key] = existing + ", " + val
} else {
h[key] = val
}
}
return h, nil
}
}
func BoolParser(value string) (any, E.NestedError) {
switch strings.ToLower(value) {
case "true", "yes", "1":
return true, nil
case "false", "no", "0":
return false, nil
default:
return nil, E.Invalid("boolean value", value)
}
}

View File

@@ -1,106 +0,0 @@
package docker
import (
"fmt"
"testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func makeLabel(namespace string, alias string, field string) string {
return fmt.Sprintf("%s.%s.%s", namespace, alias, field)
}
func TestParseLabel(t *testing.T) {
alias := "foo"
field := "ip"
v := "bar"
pl, err := ParseLabel(makeLabel(NSHomePage, alias, field), v)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Namespace, NSHomePage)
ExpectEqual(t, pl.Target, alias)
ExpectEqual(t, pl.Attribute, field)
ExpectEqual(t, pl.Value.(string), v)
}
func TestStringProxyLabel(t *testing.T) {
v := "bar"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", "ip"), v)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(string), v)
}
func TestBoolProxyLabelValid(t *testing.T) {
tests := map[string]bool{
"true": true,
"TRUE": true,
"yes": true,
"1": true,
"false": false,
"FALSE": false,
"no": false,
"0": false,
}
for k, v := range tests {
pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeNoTLSVerify), k)
ExpectNoError(t, err.Error())
ExpectEqual(t, pl.Value.(bool), v)
}
}
func TestBoolProxyLabelInvalid(t *testing.T) {
_, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeNoTLSVerify), "invalid")
if !err.Is(E.ErrInvalid) {
t.Errorf("Expected err InvalidProxyLabel, got %s", err.Error())
}
}
// func TestSetHeaderProxyLabelValid(t *testing.T) {
// v := `
// X-Custom-Header1: foo, bar
// X-Custom-Header1: baz
// X-Custom-Header2: boo`
// v = strings.TrimPrefix(v, "\n")
// h := map[string]string{
// "X-Custom-Header1": "foo, bar, baz",
// "X-Custom-Header2": "boo",
// }
// pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeSetHeaders), v)
// ExpectNoError(t, err.Error())
// hGot := ExpectType[map[string]string](t, pl.Value)
// ExpectFalse(t, hGot == nil)
// ExpectDeepEqual(t, h, hGot)
// }
// func TestSetHeaderProxyLabelInvalid(t *testing.T) {
// tests := []string{
// "X-Custom-Header1 = bar",
// "X-Custom-Header1",
// "- X-Custom-Header1",
// }
// for _, v := range tests {
// _, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeSetHeaders), v)
// if !err.Is(E.ErrInvalid) {
// t.Errorf("Expected invalid err for %q, got %s", v, err.Error())
// }
// }
// }
// func TestHideHeadersProxyLabel(t *testing.T) {
// v := `
// - X-Custom-Header1
// - X-Custom-Header2
// - X-Custom-Header3
// `
// v = strings.TrimPrefix(v, "\n")
// pl, err := ParseLabel(makeLabel(NSProxy, "foo", ProxyAttributeHideHeaders), v)
// ExpectNoError(t, err.Error())
// sGot := ExpectType[[]string](t, pl.Value)
// sWant := []string{"X-Custom-Header1", "X-Custom-Header2", "X-Custom-Header3"}
// ExpectFalse(t, sGot == nil)
// ExpectDeepEqual(t, sGot, sWant)
// }

View File

@@ -8,11 +8,15 @@ import (
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func makeLabel(ns, name, attr string) string {
return fmt.Sprintf("%s.%s.%s", ns, name, attr)
}
func TestNestedLabel(t *testing.T) {
mName := "middleware1"
mAttr := "prop1"
v := "value1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
ExpectNoError(t, err.Error())
sGot := ExpectType[*Label](t, pl.Value)
ExpectFalse(t, sGot == nil)
@@ -27,7 +31,7 @@ func TestApplyNestedLabel(t *testing.T) {
mName := "middleware1"
mAttr := "prop1"
v := "value1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
@@ -51,7 +55,7 @@ func TestApplyNestedLabelExisting(t *testing.T) {
entry.Middlewares[mName] = make(U.SerializedObject)
entry.Middlewares[mName][checkAttr] = checkV
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
@@ -76,7 +80,7 @@ func TestApplyNestedLabelNoAttr(t *testing.T) {
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", ProxyAttributeMiddlewares, mName)), v)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", "middlewares", mName)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())

View File

@@ -3,6 +3,9 @@ package docker
const (
WildcardAlias = "*"
NSProxy = "proxy"
NSHomePage = "homepage"
LabelAliases = NSProxy + ".aliases"
LabelExclude = NSProxy + ".exclude"
LabelIdleTimeout = NSProxy + ".idle_timeout"

View File

@@ -6,6 +6,7 @@ type PortMapping = map[string]types.Port
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
ContainerID string `yaml:"-" json:"container_id"`
ImageName string `yaml:"-" json:"image_name"`
PublicPortMapping PortMapping `yaml:"-" json:"public_port_mapping"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `yaml:"-" json:"private_port_mapping"` // privatePort:types.Port
@@ -14,6 +15,7 @@ type ProxyProperties struct {
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IsExplicit bool `yaml:"-" json:"is_explicit"`
IsDatabase bool `yaml:"-" json:"is_database"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`

View File

@@ -2,6 +2,7 @@ package error
import (
"fmt"
"strings"
"sync"
)
@@ -24,7 +25,6 @@ func NewBuilder(format string, args ...any) Builder {
func (b Builder) Add(err NestedError) Builder {
if err != nil {
b.Lock()
// TODO: if err severity is higher than b.severity, update b.severity
b.errors = append(b.errors, err)
b.Unlock()
}
@@ -49,8 +49,8 @@ func (b Builder) Addf(format string, args ...any) Builder {
func (b Builder) Build() NestedError {
if len(b.errors) == 0 {
return nil
} else if len(b.errors) == 1 {
return b.errors[0]
} else if len(b.errors) == 1 && !strings.ContainsRune(b.message, ' ') {
return b.errors[0].Subject(b.message)
}
return Join(b.message, b.errors...)
}

View File

@@ -166,6 +166,8 @@ func (ne NestedError) Subject(s any) NestedError {
}
if ne.subject == "" {
ne.subject = subject
} else if !strings.ContainsRune(subject, ' ') || strings.ContainsRune(ne.subject, '.') {
ne.subject = fmt.Sprintf("%s.%s", subject, ne.subject)
} else {
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
}
@@ -182,8 +184,7 @@ func (ne NestedError) Subjectf(format string, args ...any) NestedError {
if strings.Contains(format, "%w") {
panic("Subjectf format should not contain %w")
}
ne.subject = fmt.Sprintf(format, args...)
return ne
return ne.Subject(fmt.Sprintf(format, args...))
}
func (ne NestedError) JSONObject() jsonNestedError {

View File

@@ -2,17 +2,20 @@ package error
import (
stderrors "errors"
"reflect"
)
var (
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrUnexpected = stderrors.New("unexpected")
ErrNotExists = stderrors.New("does not exist")
ErrMissing = stderrors.New("missing")
ErrDuplicated = stderrors.New("duplicated")
ErrOutOfRange = stderrors.New("out of range")
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrUnexpected = stderrors.New("unexpected")
ErrNotExists = stderrors.New("does not exist")
ErrMissing = stderrors.New("missing")
ErrDuplicated = stderrors.New("duplicated")
ErrOutOfRange = stderrors.New("out of range")
ErrTypeError = stderrors.New("type error")
ErrTypeMismatch = stderrors.New("type mismatch")
)
const fmtSubjectWhat = "%w %v: %q"
@@ -57,6 +60,18 @@ func Duplicated(subject, what any) NestedError {
return errorf("%w %v: %v", ErrDuplicated, subject, what)
}
func OutOfRange(subject string, value any) NestedError {
func OutOfRange(subject any, value any) NestedError {
return errorf("%v %w: %v", subject, ErrOutOfRange, value)
}
func TypeError(subject any, from, to reflect.Type) NestedError {
return errorf("%v %w: %s -> %s\n", subject, ErrTypeError, from, to)
}
func TypeError2(subject any, from, to reflect.Value) NestedError {
return TypeError(subject, from.Type(), to.Type())
}
func TypeMismatch[Expect any](value any) NestedError {
return errorf("%w: expect %s got %T", ErrTypeMismatch, reflect.TypeFor[Expect](), value)
}

View File

@@ -0,0 +1,43 @@
package homepage
type (
HomePageConfig map[string]HomePageCategory
HomePageCategory []*HomePageItem
HomePageItem struct {
Show bool `yaml:"show" json:"show"`
Name string `yaml:"name" json:"name"`
Icon string `yaml:"icon" json:"icon"`
URL string `yaml:"url" json:"url"` // alias + domain
Category string `yaml:"category" json:"category"`
Description string `yaml:"description" json:"description"`
WidgetConfig map[string]any `yaml:",flow" json:"widget_config"`
SourceType string `yaml:"-" json:"source_type"`
AltURL string `yaml:"-" json:"alt_url"` // original proxy target
}
)
func (item *HomePageItem) IsEmpty() bool {
return item == nil || (item.Name == "" &&
item.Icon == "" &&
item.URL == "" &&
item.Category == "" &&
item.Description == "" &&
len(item.WidgetConfig) == 0)
}
func NewHomePageConfig() HomePageConfig {
return HomePageConfig(make(map[string]HomePageCategory))
}
func (c *HomePageConfig) Clear() {
*c = make(HomePageConfig)
}
func (c HomePageConfig) Add(item *HomePageItem) {
if c[item.Category] == nil {
c[item.Category] = make(HomePageCategory, 0)
}
c[item.Category] = append(c[item.Category], item)
}

View File

@@ -1,42 +0,0 @@
package http
import (
"net/http"
"slices"
)
func RemoveHop(h http.Header) {
reqUpType := UpgradeType(h)
RemoveHopByHopHeaders(h)
if reqUpType != "" {
h.Set("Connection", "Upgrade")
h.Set("Upgrade", reqUpType)
} else {
h.Del("Connection")
}
}
func CopyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func FilterHeaders(h http.Header, allowed []string) {
if allowed == nil {
return
}
for i := range allowed {
allowed[i] = http.CanonicalHeaderKey(allowed[i])
}
for key := range h {
if !slices.Contains(allowed, key) {
h.Del(key)
}
}
}

99
internal/list-icons.go Normal file
View File

@@ -0,0 +1,99 @@
package internal
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"log"
"github.com/yusing/go-proxy/internal/utils"
)
type GitHubContents struct { //! keep this, may reuse in future
Type string `json:"type"`
Path string `json:"path"`
Name string `json:"name"`
Sha string `json:"sha"`
Size int `json:"size"`
}
const iconsCachePath = "/tmp/icons_cache.json"
const updateInterval = 1 * time.Hour
func ListAvailableIcons() ([]string, error) {
owner := "walkxcode"
repo := "dashboard-icons"
ref := "main"
var lastUpdate time.Time
var icons = make([]string, 0)
info, err := os.Stat(iconsCachePath)
if err == nil {
lastUpdate = info.ModTime().Local()
}
if time.Since(lastUpdate) < updateInterval {
err := utils.LoadJson(iconsCachePath, &icons)
if err == nil {
return icons, nil
}
}
contents, err := getRepoContents(http.DefaultClient, owner, repo, ref, "")
if err != nil {
return nil, err
}
for _, content := range contents {
if content.Type != "dir" {
icons = append(icons, content.Path)
}
}
err = utils.SaveJson(iconsCachePath, &icons, 0o644).Error()
if err != nil {
log.Print("error saving cache", err)
}
return icons, nil
}
func getRepoContents(client *http.Client, owner string, repo string, ref string, path string) ([]GitHubContents, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, path, ref), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var contents []GitHubContents
err = json.Unmarshal(body, &contents)
if err != nil {
return nil, err
}
filesAndDirs := make([]GitHubContents, 0)
for _, content := range contents {
if content.Type == "dir" {
subContents, err := getRepoContents(client, owner, repo, ref, content.Path)
if err != nil {
return nil, err
}
filesAndDirs = append(filesAndDirs, subContents...)
} else {
filesAndDirs = append(filesAndDirs, content)
}
}
return filesAndDirs, nil
}

View File

@@ -1,4 +1,4 @@
package common
package http
import (
"crypto/tls"
@@ -13,9 +13,13 @@ var (
KeepAlive: 60 * time.Second,
}
DefaultTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
MaxIdleConnsPerHost: 1000,
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
DefaultTransportNoTLS = func() *http.Transport {
var clone = DefaultTransport.Clone()

View File

@@ -0,0 +1,53 @@
package http
import (
"net/http"
)
func RemoveHop(h http.Header) {
reqUpType := UpgradeType(h)
RemoveHopByHopHeaders(h)
if reqUpType != "" {
h.Set("Connection", "Upgrade")
h.Set("Upgrade", reqUpType)
} else {
h.Del("Connection")
}
}
func CopyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func FilterHeaders(h http.Header, allowed []string) http.Header {
if len(allowed) == 0 {
return h
}
filtered := make(http.Header)
for i, header := range allowed {
values := h.Values(header)
if len(values) == 0 {
continue
}
filtered[http.CanonicalHeaderKey(allowed[i])] = append([]string(nil), values...)
}
return filtered
}
func HeaderToMap(h http.Header) map[string]string {
result := make(map[string]string)
for k, v := range h {
if len(v) > 0 {
result[k] = v[0] // Take the first value
}
}
return result
}

View File

@@ -0,0 +1,83 @@
package middleware
import (
"net"
"net/http"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/types"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type cidrWhitelist struct {
*cidrWhitelistOpts
m *Middleware
}
type cidrWhitelistOpts struct {
Allow []*types.CIDR
StatusCode int
Message string
cachedAddr F.Map[string, bool] // cache for trusted IPs
}
var CIDRWhiteList = &cidrWhitelist{
m: &Middleware{withOptions: NewCIDRWhitelist},
}
var cidrWhitelistDefaults = func() *cidrWhitelistOpts {
return &cidrWhitelistOpts{
Allow: []*types.CIDR{},
StatusCode: http.StatusForbidden,
Message: "IP not allowed",
cachedAddr: F.NewMapOf[string, bool](),
}
}
func NewCIDRWhitelist(opts OptionsRaw) (*Middleware, E.NestedError) {
wl := new(cidrWhitelist)
wl.m = &Middleware{
impl: wl,
before: wl.checkIP,
}
wl.cidrWhitelistOpts = cidrWhitelistDefaults()
err := Deserialize(opts, wl.cidrWhitelistOpts)
if err != nil {
return nil, err
}
if len(wl.cidrWhitelistOpts.Allow) == 0 {
return nil, E.Missing("allow range")
}
return wl.m, nil
}
func (wl *cidrWhitelist) checkIP(next http.HandlerFunc, w ResponseWriter, r *Request) {
var allow, ok bool
if allow, ok = wl.cachedAddr.Load(r.RemoteAddr); !ok {
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
ipStr = r.RemoteAddr
}
ip := net.ParseIP(ipStr)
for _, cidr := range wl.cidrWhitelistOpts.Allow {
if cidr.Contains(ip) {
wl.cachedAddr.Store(r.RemoteAddr, true)
allow = true
wl.m.AddTracef("client %s is allowed", ipStr).With("allowed CIDR", cidr)
break
}
}
if !allow {
wl.cachedAddr.Store(r.RemoteAddr, false)
wl.m.AddTracef("client %s is forbidden", ipStr).With("allowed CIDRs", wl.cidrWhitelistOpts.Allow)
}
}
if !allow {
w.WriteHeader(wl.StatusCode)
w.Write([]byte(wl.Message))
return
}
next(w, r)
}

View File

@@ -0,0 +1,42 @@
package middleware
import (
_ "embed"
"net/http"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
//go:embed test_data/cidr_whitelist_test.yml
var testCIDRWhitelistCompose []byte
var deny, accept *Middleware
func TestCIDRWhitelist(t *testing.T) {
mids, err := BuildMiddlewaresFromYAML(testCIDRWhitelistCompose)
if err != nil {
panic(err)
}
deny = mids["deny@file"]
accept = mids["accept@file"]
if deny == nil || accept == nil {
panic("bug occurred")
}
t.Run("deny", func(t *testing.T) {
for range 10 {
result, err := newMiddlewareTest(deny, nil)
ExpectNoError(t, err.Error())
ExpectEqual(t, result.ResponseStatus, cidrWhitelistDefaults().StatusCode)
ExpectEqual(t, string(result.Data), cidrWhitelistDefaults().Message)
}
})
t.Run("accept", func(t *testing.T) {
for range 10 {
result, err := newMiddlewareTest(accept, nil)
ExpectNoError(t, err.Error())
ExpectEqual(t, result.ResponseStatus, http.StatusOK)
}
})
}

View File

@@ -0,0 +1,118 @@
package middleware
import (
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/types"
)
const (
cfIPv4CIDRsEndpoint = "https://www.cloudflare.com/ips-v4"
cfIPv6CIDRsEndpoint = "https://www.cloudflare.com/ips-v6"
cfCIDRsUpdateInterval = time.Hour
cfCIDRsUpdateRetryInterval = 3 * time.Second
)
var (
cfCIDRsLastUpdate time.Time
cfCIDRsMu sync.Mutex
cfCIDRsLogger = logrus.WithField("middleware", "CloudflareRealIP")
)
var CloudflareRealIP = &realIP{
m: &Middleware{withOptions: NewCloudflareRealIP},
}
func NewCloudflareRealIP(_ OptionsRaw) (*Middleware, E.NestedError) {
cri := new(realIP)
cri.m = &Middleware{
impl: cri,
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
cidrs := tryFetchCFCIDR()
if cidrs != nil {
cri.From = cidrs
}
cri.setRealIP(r)
next(w, r)
},
}
cri.realIPOpts = &realIPOpts{
Header: "CF-Connecting-IP",
Recursive: true,
}
return cri.m, nil
}
func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) {
if time.Since(cfCIDRsLastUpdate) < cfCIDRsUpdateInterval {
return
}
cfCIDRsMu.Lock()
defer cfCIDRsMu.Unlock()
if time.Since(cfCIDRsLastUpdate) < cfCIDRsUpdateInterval {
return
}
if common.IsTest {
cfCIDRs = []*types.CIDR{
{IP: net.IPv4(127, 0, 0, 1), Mask: net.IPv4Mask(255, 0, 0, 0)},
{IP: net.IPv4(10, 0, 0, 0), Mask: net.IPv4Mask(255, 0, 0, 0)},
{IP: net.IPv4(172, 16, 0, 0), Mask: net.IPv4Mask(255, 255, 0, 0)},
{IP: net.IPv4(192, 168, 0, 0), Mask: net.IPv4Mask(255, 255, 255, 0)},
}
} else {
cfCIDRs = make([]*types.CIDR, 0, 30)
err := errors.Join(
fetchUpdateCFIPRange(cfIPv4CIDRsEndpoint, cfCIDRs),
fetchUpdateCFIPRange(cfIPv6CIDRsEndpoint, cfCIDRs),
)
if err != nil {
cfCIDRsLastUpdate = time.Now().Add(-cfCIDRsUpdateRetryInterval - cfCIDRsUpdateInterval)
cfCIDRsLogger.Errorf("failed to update cloudflare range: %s, retry in %s", err, cfCIDRsUpdateRetryInterval)
return nil
}
}
cfCIDRsLastUpdate = time.Now()
cfCIDRsLogger.Debugf("cloudflare CIDR range updated")
return
}
func fetchUpdateCFIPRange(endpoint string, cfCIDRs []*types.CIDR) error {
resp, err := http.Get(endpoint)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
for _, line := range strings.Split(string(body), "\n") {
if line == "" {
continue
}
_, cidr, err := net.ParseCIDR(line)
if err != nil {
return fmt.Errorf("cloudflare responeded an invalid CIDR: %s", line)
} else {
cfCIDRs = append(cfCIDRs, (*types.CIDR)(cidr))
}
}
return nil
}

View File

@@ -10,20 +10,19 @@ import (
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/api/v1/error_page"
"github.com/yusing/go-proxy/internal/common"
gpHTTP "github.com/yusing/go-proxy/internal/http"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
var CustomErrorPage = &Middleware{
before: func(next http.Handler, w ResponseWriter, r *Request) {
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
if !ServeStaticErrorPageFile(w, r) {
next.ServeHTTP(w, r)
next(w, r)
}
},
modifyResponse: func(resp *Response) error {
// only handles non-success status code and html/plain content type
contentType := gpHTTP.GetContentType(resp.Header)
if !gpHTTP.IsSuccess(resp.StatusCode) && (contentType.IsHTML() || contentType.IsPlainText()) {
contentType := gphttp.GetContentType(resp.Header)
if !gphttp.IsSuccess(resp.StatusCode) && (contentType.IsHTML() || contentType.IsPlainText()) {
errorPage, ok := error_page.GetErrorPageByStatus(resp.StatusCode)
if ok {
errPageLogger.Debugf("error page for status %d loaded", resp.StatusCode)
@@ -47,8 +46,8 @@ func ServeStaticErrorPageFile(w http.ResponseWriter, r *http.Request) bool {
if path != "" && path[0] != '/' {
path = "/" + path
}
if strings.HasPrefix(path, common.StaticFilePathPrefix) {
filename := path[len(common.StaticFilePathPrefix):]
if strings.HasPrefix(path, gphttp.StaticFilePathPrefix) {
filename := path[len(gphttp.StaticFilePathPrefix):]
file, ok := error_page.GetStaticFile(filename)
if !ok {
errPageLogger.Errorf("unable to load resource %s", filename)

View File

@@ -13,12 +13,8 @@ import (
"strings"
"time"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
gpHTTP "github.com/yusing/go-proxy/internal/http"
U "github.com/yusing/go-proxy/internal/utils"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
type (
@@ -32,66 +28,51 @@ type (
TrustForwardHeader bool
AuthResponseHeaders []string
AddAuthCookiesToResponse []string
transport http.RoundTripper
}
)
const (
xForwardedFor = "X-Forwarded-For"
xForwardedMethod = "X-Forwarded-Method"
xForwardedHost = "X-Forwarded-Host"
xForwardedProto = "X-Forwarded-Proto"
xForwardedURI = "X-Forwarded-Uri"
xForwardedPort = "X-Forwarded-Port"
)
var ForwardAuth = newForwardAuth()
var faLogger = logrus.WithField("middleware", "ForwardAuth")
func newForwardAuth() (fa *forwardAuth) {
fa = new(forwardAuth)
fa.m = new(Middleware)
fa.m.labelParserMap = D.ValueParserMap{
"trust_forward_header": D.BoolParser,
"auth_response_headers": D.YamlStringListParser,
"add_auth_cookies_to_response": D.YamlStringListParser,
}
fa.m.withOptions = func(optsRaw OptionsRaw, rp *ReverseProxy) (*Middleware, E.NestedError) {
tr, ok := rp.Transport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
tr = common.DefaultTransport.Clone()
}
faWithOpts := new(forwardAuth)
faWithOpts.forwardAuthOpts = new(forwardAuthOpts)
faWithOpts.client = http.Client{
CheckRedirect: func(r *Request, via []*Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
Transport: tr,
}
faWithOpts.m = &Middleware{
impl: faWithOpts,
before: faWithOpts.forward,
}
err := U.Deserialize(optsRaw, faWithOpts.forwardAuthOpts)
if err != nil {
return nil, E.FailWith("set options", err)
}
_, err = E.Check(url.Parse(faWithOpts.Address))
if err != nil {
return nil, E.Invalid("address", faWithOpts.Address)
}
return faWithOpts.m, nil
}
return
var ForwardAuth = &forwardAuth{
m: &Middleware{withOptions: NewForwardAuthfunc},
}
func (fa *forwardAuth) forward(next http.Handler, w ResponseWriter, req *Request) {
gpHTTP.RemoveHop(req.Header)
func NewForwardAuthfunc(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
fa := new(forwardAuth)
fa.forwardAuthOpts = new(forwardAuthOpts)
err := Deserialize(optsRaw, fa.forwardAuthOpts)
if err != nil {
return nil, err
}
_, err = E.Check(url.Parse(fa.Address))
if err != nil {
return nil, E.Invalid("address", fa.Address)
}
fa.m = &Middleware{
impl: fa,
before: fa.forward,
}
// TODO: use tr from reverse proxy
tr, ok := fa.transport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
tr = gphttp.DefaultTransport.Clone()
}
fa.client = http.Client{
CheckRedirect: func(r *Request, via []*Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
Transport: tr,
}
return fa.m, nil
}
func (fa *forwardAuth) forward(next http.HandlerFunc, w ResponseWriter, req *Request) {
gphttp.RemoveHop(req.Header)
faReq, err := http.NewRequestWithContext(
req.Context(),
@@ -100,20 +81,21 @@ func (fa *forwardAuth) forward(next http.Handler, w ResponseWriter, req *Request
nil,
)
if err != nil {
faLogger.Debugf("new request err to %s: %s", fa.Address, err)
fa.m.AddTracef("new request err to %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
gpHTTP.CopyHeader(faReq.Header, req.Header)
gpHTTP.RemoveHop(faReq.Header)
gphttp.CopyHeader(faReq.Header, req.Header)
gphttp.RemoveHop(faReq.Header)
gpHTTP.FilterHeaders(faReq.Header, fa.AuthResponseHeaders)
faReq.Header = gphttp.FilterHeaders(faReq.Header, fa.AuthResponseHeaders)
fa.setAuthHeaders(req, faReq)
fa.m.AddTraceRequest("forward auth request", faReq)
faResp, err := fa.client.Do(faReq)
if err != nil {
faLogger.Debugf("failed to call %s: %s", fa.Address, err)
fa.m.AddTracef("failed to call %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
@@ -121,28 +103,30 @@ func (fa *forwardAuth) forward(next http.Handler, w ResponseWriter, req *Request
body, err := io.ReadAll(faResp.Body)
if err != nil {
faLogger.Debugf("failed to read response body from %s: %s", fa.Address, err)
fa.m.AddTracef("failed to read response body from %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if faResp.StatusCode < http.StatusOK || faResp.StatusCode >= http.StatusMultipleChoices {
gpHTTP.CopyHeader(w.Header(), faResp.Header)
gpHTTP.RemoveHop(w.Header())
fa.m.AddTraceResponse("forward auth response", faResp)
gphttp.CopyHeader(w.Header(), faResp.Header)
gphttp.RemoveHop(w.Header())
redirectURL, err := faResp.Location()
if err != nil {
faLogger.Debugf("failed to get location from %s: %s", fa.Address, err)
fa.m.AddTracef("failed to get location from %s", fa.Address).WithError(err).WithResponse(faResp)
w.WriteHeader(http.StatusInternalServerError)
return
} else if redirectURL.String() != "" {
w.Header().Set("Location", redirectURL.String())
fa.m.AddTracef("redirect to %q", redirectURL.String()).WithResponse(faResp)
}
w.WriteHeader(faResp.StatusCode)
if _, err = w.Write(body); err != nil {
faLogger.Debugf("failed to write response body from %s: %s", fa.Address, err)
fa.m.AddTracef("failed to write response body from %s", fa.Address).WithError(err).WithResponse(faResp)
}
return
}
@@ -164,7 +148,7 @@ func (fa *forwardAuth) forward(next http.Handler, w ResponseWriter, req *Request
return
}
next.ServeHTTP(gpHTTP.NewModifyResponseWriter(w, req, func(resp *Response) error {
next.ServeHTTP(gphttp.NewModifyResponseWriter(w, req, func(resp *Response) error {
fa.setAuthCookies(resp, authCookies)
return nil
}), req)

View File

@@ -0,0 +1,155 @@
package middleware
import (
"encoding/json"
"errors"
"net/http"
E "github.com/yusing/go-proxy/internal/error"
gphttp "github.com/yusing/go-proxy/internal/net/http"
U "github.com/yusing/go-proxy/internal/utils"
)
type (
Error = E.NestedError
ReverseProxy = gphttp.ReverseProxy
ProxyRequest = gphttp.ProxyRequest
Request = http.Request
Response = http.Response
ResponseWriter = http.ResponseWriter
Header = http.Header
Cookie = http.Cookie
BeforeFunc func(next http.HandlerFunc, w ResponseWriter, r *Request)
RewriteFunc func(req *Request)
ModifyResponseFunc func(resp *Response) error
CloneWithOptFunc func(opts OptionsRaw) (*Middleware, E.NestedError)
OptionsRaw = map[string]any
Options any
Middleware struct {
name string
before BeforeFunc // runs before ReverseProxy.ServeHTTP
modifyResponse ModifyResponseFunc // runs after ReverseProxy.ModifyResponse
withOptions CloneWithOptFunc
impl any
parent *Middleware
children []*Middleware
trace bool
}
)
var Deserialize = U.Deserialize
func Rewrite(r RewriteFunc) BeforeFunc {
return func(next http.HandlerFunc, w ResponseWriter, req *Request) {
r(req)
next(w, req)
}
}
func (m *Middleware) Name() string {
return m.name
}
func (m *Middleware) Fullname() string {
if m.parent != nil {
return m.parent.Fullname() + "." + m.name
}
return m.name
}
func (m *Middleware) String() string {
return m.name
}
func (m *Middleware) MarshalJSON() ([]byte, error) {
return json.MarshalIndent(map[string]any{
"name": m.name,
"options": m.impl,
}, "", " ")
}
func (m *Middleware) WithOptionsClone(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
if len(optsRaw) != 0 && m.withOptions != nil {
if mWithOpt, err := m.withOptions(optsRaw); err != nil {
return nil, err
} else {
return mWithOpt, nil
}
}
// WithOptionsClone is called only once
// set withOptions and labelParser will not be used after that
return &Middleware{
m.name,
m.before,
m.modifyResponse,
nil,
m.impl,
m.parent,
m.children,
false,
}, nil
}
// TODO: check conflict or duplicates
func PatchReverseProxy(rpName string, rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) (res E.NestedError) {
middlewares := make([]*Middleware, 0, len(middlewaresMap))
invalidM := E.NewBuilder("invalid middlewares")
invalidOpts := E.NewBuilder("invalid options")
defer func() {
invalidM.Add(invalidOpts.Build())
invalidM.To(&res)
}()
for name, opts := range middlewaresMap {
m, ok := Get(name)
if !ok {
invalidM.Add(E.NotExist("middleware", name))
continue
}
m, err := m.WithOptionsClone(opts)
if err != nil {
invalidOpts.Add(err.Subject(name))
continue
}
middlewares = append(middlewares, m)
}
if invalidM.HasError() {
return
}
patchReverseProxy(rpName, rp, middlewares)
return
}
func patchReverseProxy(rpName string, rp *ReverseProxy, middlewares []*Middleware) {
mid := BuildMiddlewareFromChain(rpName, middlewares)
if mid.before != nil {
ori := rp.ServeHTTP
rp.ServeHTTP = func(w http.ResponseWriter, r *http.Request) {
mid.before(ori, w, r)
}
}
if mid.modifyResponse != nil {
if rp.ModifyResponse != nil {
ori := rp.ModifyResponse
rp.ModifyResponse = func(res *http.Response) error {
return errors.Join(mid.modifyResponse(res), ori(res))
}
} else {
rp.ModifyResponse = mid.modifyResponse
}
}
}

View File

@@ -0,0 +1,114 @@
package middleware
import (
"fmt"
"net/http"
"os"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
"gopkg.in/yaml.v3"
)
func BuildMiddlewaresFromComposeFile(filePath string) (map[string]*Middleware, E.NestedError) {
fileContent, err := os.ReadFile(filePath)
if err != nil {
return nil, E.FailWith("read middleware compose file", err)
}
return BuildMiddlewaresFromYAML(fileContent)
}
func BuildMiddlewaresFromYAML(data []byte) (middlewares map[string]*Middleware, outErr E.NestedError) {
b := E.NewBuilder("middlewares compile errors")
defer b.To(&outErr)
var rawMap map[string][]map[string]any
err := yaml.Unmarshal(data, &rawMap)
if err != nil {
b.Add(E.FailWith("yaml unmarshal", err))
return
}
middlewares = make(map[string]*Middleware)
for name, defs := range rawMap {
chainErr := E.NewBuilder(name)
chain := make([]*Middleware, 0, len(defs))
for i, def := range defs {
if def["use"] == nil || def["use"] == "" {
chainErr.Add(E.Missing("use").Subjectf(".%d", i))
continue
}
baseName := def["use"].(string)
base, ok := Get(baseName)
if !ok {
base, ok = middlewares[baseName]
if !ok {
chainErr.Add(E.NotExist("middleware", baseName).Subjectf(".%d", i))
continue
}
}
delete(def, "use")
m, err := base.WithOptionsClone(def)
m.name = fmt.Sprintf("%s[%d]", name, i)
if err != nil {
chainErr.Add(err.Subjectf("item%d", i))
continue
}
chain = append(chain, m)
}
if chainErr.HasError() {
b.Add(chainErr.Build())
} else {
middlewares[name+"@file"] = BuildMiddlewareFromChain(name, chain)
}
}
return
}
// TODO: check conflict or duplicates
func BuildMiddlewareFromChain(name string, chain []*Middleware) *Middleware {
m := &Middleware{name: name, children: chain}
var befores []*Middleware
var modResps []*Middleware
for _, comp := range chain {
if comp.before != nil {
befores = append(befores, comp)
}
if comp.modifyResponse != nil {
modResps = append(modResps, comp)
}
comp.parent = m
}
if len(befores) > 0 {
m.before = buildBefores(befores)
}
if len(modResps) > 0 {
m.modifyResponse = func(res *Response) error {
b := E.NewBuilder("errors in middleware")
for _, mr := range modResps {
b.Add(E.From(mr.modifyResponse(res)).Subject(mr.name))
}
return b.Build().Error()
}
}
if common.IsDebug {
m.EnableTrace()
m.AddTracef("middleware created")
}
return m
}
func buildBefores(befores []*Middleware) BeforeFunc {
if len(befores) == 1 {
return befores[0].before
}
nextBefores := buildBefores(befores[1:])
return func(next http.HandlerFunc, w ResponseWriter, r *Request) {
befores[0].before(func(w ResponseWriter, r *Request) {
nextBefores(next, w, r)
}, w, r)
}
}

View File

@@ -0,0 +1,22 @@
package middleware
import (
_ "embed"
"encoding/json"
"testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
//go:embed test_data/middleware_compose.yml
var testMiddlewareCompose []byte
func TestBuild(t *testing.T) {
middlewares, err := BuildMiddlewaresFromYAML(testMiddlewareCompose)
ExpectNoError(t, err.Error())
_, err = E.Check(json.MarshalIndent(middlewares, "", " "))
ExpectNoError(t, err.Error())
// t.Log(string(data))
// TODO: test
}

View File

@@ -0,0 +1,78 @@
package middleware
import (
"fmt"
"net/http"
"path"
"strings"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
)
var middlewares map[string]*Middleware
func Get(name string) (middleware *Middleware, ok bool) {
middleware, ok = middlewares[U.ToLowerNoSnake(name)]
return
}
func All() map[string]*Middleware {
return middlewares
}
// initialize middleware names and label parsers
func init() {
middlewares = map[string]*Middleware{
"setxforwarded": SetXForwarded,
"hidexforwarded": HideXForwarded,
"redirecthttp": RedirectHTTP,
"forwardauth": ForwardAuth.m,
"modifyresponse": ModifyResponse.m,
"modifyrequest": ModifyRequest.m,
"errorpage": CustomErrorPage,
"customerrorpage": CustomErrorPage,
"realip": RealIP.m,
"cloudflarerealip": CloudflareRealIP.m,
"cidrwhitelist": CIDRWhiteList.m,
}
names := make(map[*Middleware][]string)
for name, m := range middlewares {
names[m] = append(names[m], http.CanonicalHeaderKey(name))
}
for m, names := range names {
if len(names) > 1 {
m.name = fmt.Sprintf("%s (a.k.a. %s)", names[0], strings.Join(names[1:], ", "))
} else {
m.name = names[0]
}
}
}
func LoadComposeFiles() {
b := E.NewBuilder("failed to load middlewares")
middlewareDefs, err := U.ListFiles(common.MiddlewareComposeBasePath, 0)
if err != nil {
logrus.Errorf("failed to list middleware definitions: %s", err)
return
}
for _, defFile := range middlewareDefs {
mws, err := BuildMiddlewaresFromComposeFile(defFile)
for name, m := range mws {
if _, ok := middlewares[name]; ok {
b.Add(E.Duplicated("middleware", name))
continue
}
middlewares[U.ToLowerNoSnake(name)] = m
logger.Infof("middleware %s loaded from %s", name, path.Base(defFile))
}
b.Add(err.Subject(path.Base(defFile)))
}
if b.HasError() {
logger.Error(b.Build())
}
}
var logger = logrus.WithField("module", "middlewares")

View File

@@ -0,0 +1,61 @@
package middleware
import (
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
)
type (
modifyRequest struct {
*modifyRequestOpts
m *Middleware
}
// order: set_headers -> add_headers -> hide_headers
modifyRequestOpts struct {
SetHeaders map[string]string
AddHeaders map[string]string
HideHeaders []string
}
)
var ModifyRequest = &modifyRequest{
m: &Middleware{withOptions: NewModifyRequest},
}
func NewModifyRequest(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
mr := new(modifyRequest)
var mrFunc RewriteFunc
if common.IsDebug {
mrFunc = mr.modifyRequestWithTrace
} else {
mrFunc = mr.modifyRequest
}
mr.m = &Middleware{
impl: mr,
before: Rewrite(mrFunc),
}
mr.modifyRequestOpts = new(modifyRequestOpts)
err := Deserialize(optsRaw, mr.modifyRequestOpts)
if err != nil {
return nil, err
}
return mr.m, nil
}
func (mr *modifyRequest) modifyRequest(req *Request) {
for k, v := range mr.SetHeaders {
req.Header.Set(k, v)
}
for k, v := range mr.AddHeaders {
req.Header.Add(k, v)
}
for _, k := range mr.HideHeaders {
req.Header.Del(k)
}
}
func (mr *modifyRequest) modifyRequestWithTrace(req *Request) {
mr.m.AddTraceRequest("before modify request", req)
mr.modifyRequest(req)
mr.m.AddTraceRequest("after modify request", req)
}

View File

@@ -15,7 +15,7 @@ func TestSetModifyRequest(t *testing.T) {
}
t.Run("set_options", func(t *testing.T) {
mr, err := ModifyRequest.m.WithOptionsClone(opts, nil)
mr, err := ModifyRequest.m.WithOptionsClone(opts)
ExpectNoError(t, err.Error())
ExpectDeepEqual(t, mr.impl.(*modifyRequest).SetHeaders, opts["set_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyRequest).AddHeaders, opts["add_headers"].(map[string]string))

View File

@@ -0,0 +1,61 @@
package middleware
import (
"net/http"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
)
type (
modifyResponse struct {
*modifyResponseOpts
m *Middleware
}
// order: set_headers -> add_headers -> hide_headers
modifyResponseOpts struct {
SetHeaders map[string]string
AddHeaders map[string]string
HideHeaders []string
}
)
var ModifyResponse = &modifyResponse{
m: &Middleware{withOptions: NewModifyResponse},
}
func NewModifyResponse(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
mr := new(modifyResponse)
mr.m = &Middleware{impl: mr}
if common.IsDebug {
mr.m.modifyResponse = mr.modifyResponseWithTrace
} else {
mr.m.modifyResponse = mr.modifyResponse
}
mr.modifyResponseOpts = new(modifyResponseOpts)
err := Deserialize(optsRaw, mr.modifyResponseOpts)
if err != nil {
return nil, err
}
return mr.m, nil
}
func (mr *modifyResponse) modifyResponse(resp *http.Response) error {
for k, v := range mr.SetHeaders {
resp.Header.Set(k, v)
}
for k, v := range mr.AddHeaders {
resp.Header.Add(k, v)
}
for _, k := range mr.HideHeaders {
resp.Header.Del(k)
}
return nil
}
func (mr *modifyResponse) modifyResponseWithTrace(resp *http.Response) error {
mr.m.AddTraceResponse("before modify response", resp)
err := mr.modifyResponse(resp)
mr.m.AddTraceResponse("after modify response", resp)
return err
}

View File

@@ -15,7 +15,7 @@ func TestSetModifyResponse(t *testing.T) {
}
t.Run("set_options", func(t *testing.T) {
mr, err := ModifyResponse.m.WithOptionsClone(opts, nil)
mr, err := ModifyResponse.m.WithOptionsClone(opts)
ExpectNoError(t, err.Error())
ExpectDeepEqual(t, mr.impl.(*modifyResponse).SetHeaders, opts["set_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyResponse).AddHeaders, opts["add_headers"].(map[string]string))

View File

@@ -0,0 +1,115 @@
package middleware
import (
"net"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/types"
)
// https://nginx.org/en/docs/http/ngx_http_realip_module.html
type realIP struct {
*realIPOpts
m *Middleware
}
type realIPOpts struct {
// Header is the name of the header to use for the real client IP
Header string
// From is a list of Address / CIDRs to trust
From []*types.CIDR
/*
If recursive search is disabled,
the original client address that matches one of the trusted addresses is replaced by
the last address sent in the request header field defined by the Header field.
If recursive search is enabled,
the original client address that matches one of the trusted addresses is replaced by
the last non-trusted address sent in the request header field.
*/
Recursive bool
}
var RealIP = &realIP{
m: &Middleware{withOptions: NewRealIP},
}
var realIPOptsDefault = func() *realIPOpts {
return &realIPOpts{
Header: "X-Real-IP",
From: []*types.CIDR{},
}
}
func NewRealIP(opts OptionsRaw) (*Middleware, E.NestedError) {
riWithOpts := new(realIP)
riWithOpts.m = &Middleware{
impl: riWithOpts,
before: Rewrite(riWithOpts.setRealIP),
}
riWithOpts.realIPOpts = realIPOptsDefault()
err := Deserialize(opts, riWithOpts.realIPOpts)
if err != nil {
return nil, err
}
return riWithOpts.m, nil
}
func (ri *realIP) isInCIDRList(ip net.IP) bool {
for _, CIDR := range ri.From {
if CIDR.Contains(ip) {
return true
}
}
// not in any CIDR
return false
}
func (ri *realIP) setRealIP(req *Request) {
clientIPStr, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
clientIPStr = req.RemoteAddr
}
clientIP := net.ParseIP(clientIPStr)
var isTrusted = false
for _, CIDR := range ri.From {
if CIDR.Contains(clientIP) {
isTrusted = true
break
}
}
if !isTrusted {
ri.m.AddTracef("client ip %s is not trusted", clientIP).With("allowed CIDRs", ri.From)
return
}
var realIPs = req.Header.Values(ri.Header)
var lastNonTrustedIP string
if len(realIPs) == 0 {
ri.m.AddTracef("no real ip found in header %s", ri.Header).WithRequest(req)
return
}
if !ri.Recursive {
lastNonTrustedIP = realIPs[len(realIPs)-1]
} else {
for _, r := range realIPs {
if !ri.isInCIDRList(net.ParseIP(r)) {
lastNonTrustedIP = r
}
}
}
if lastNonTrustedIP == "" {
ri.m.AddTracef("no non-trusted ip found").With("allowed CIDRs", ri.From).With("ips", realIPs)
return
}
req.RemoteAddr = lastNonTrustedIP
req.Header.Set(ri.Header, lastNonTrustedIP)
req.Header.Set("X-Real-IP", lastNonTrustedIP)
req.Header.Set(xForwardedFor, lastNonTrustedIP)
ri.m.AddTracef("set real ip %s", lastNonTrustedIP)
}

View File

@@ -0,0 +1,77 @@
package middleware
import (
"net"
"net/http"
"strings"
"testing"
"github.com/yusing/go-proxy/internal/types"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestSetRealIPOpts(t *testing.T) {
opts := OptionsRaw{
"header": "X-Real-IP",
"from": []string{
"127.0.0.0/8",
"192.168.0.0/16",
"172.16.0.0/12",
},
"recursive": true,
}
optExpected := &realIPOpts{
Header: "X-Real-IP",
From: []*types.CIDR{
{
IP: net.ParseIP("127.0.0.0"),
Mask: net.IPv4Mask(255, 0, 0, 0),
},
{
IP: net.ParseIP("192.168.0.0"),
Mask: net.IPv4Mask(255, 255, 0, 0),
},
{
IP: net.ParseIP("172.16.0.0"),
Mask: net.IPv4Mask(255, 240, 0, 0),
},
},
Recursive: true,
}
ri, err := NewRealIP(opts)
ExpectNoError(t, err.Error())
ExpectEqual(t, ri.impl.(*realIP).Header, optExpected.Header)
ExpectEqual(t, ri.impl.(*realIP).Recursive, optExpected.Recursive)
for i, CIDR := range ri.impl.(*realIP).From {
ExpectEqual(t, CIDR.String(), optExpected.From[i].String())
}
}
func TestSetRealIP(t *testing.T) {
const (
testHeader = "X-Real-IP"
testRealIP = "192.168.1.1"
)
opts := OptionsRaw{
"header": testHeader,
"from": []string{"0.0.0.0/0"},
}
optsMr := OptionsRaw{
"set_headers": map[string]string{testHeader: testRealIP},
}
realip, err := NewRealIP(opts)
ExpectNoError(t, err.Error())
mr, err := NewModifyRequest(optsMr)
ExpectNoError(t, err.Error())
mid := BuildMiddlewareFromChain("test", []*Middleware{mr, realip})
result, err := newMiddlewareTest(mid, nil)
ExpectNoError(t, err.Error())
t.Log(traces)
ExpectEqual(t, result.ResponseStatus, http.StatusOK)
ExpectEqual(t, strings.Split(result.RemoteAddr, ":")[0], testRealIP)
ExpectEqual(t, result.RequestHeaders.Get(xForwardedFor), testRealIP)
}

View File

@@ -7,13 +7,13 @@ import (
)
var RedirectHTTP = &Middleware{
before: func(next http.Handler, w ResponseWriter, r *Request) {
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
if r.TLS == nil {
r.URL.Scheme = "https"
r.URL.Host = r.URL.Hostname() + ":" + common.ProxyHTTPSPort
http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect)
return
}
next.ServeHTTP(w, r)
next(w, r)
},
}

View File

@@ -0,0 +1,22 @@
deny:
- use: ModifyRequest
setHeaders:
X-Real-IP: 192.168.1.1:1234
- use: RealIP
header: X-Real-IP
from:
- 0.0.0.0/0
- use: CIDRWhitelist
allow:
- 192.168.0.0/24
accept:
- use: ModifyRequest
setHeaders:
X-Real-IP: 192.168.0.1:1234
- use: RealIP
header: X-Real-IP
from:
- 0.0.0.0/0
- use: CIDRWhitelist
allow:
- 192.168.0.0/24

View File

@@ -0,0 +1,41 @@
theGreatPretender:
- use: HideXForwarded
- use: ModifyRequest
setHeaders:
X-Real-IP: 6.6.6.6
- use: ModifyResponse
hideHeaders:
- X-Test3
- X-Test4
notAuthenticAuthentik:
- use: RedirectHTTP
- use: ForwardAuth
address: https://authentik.company
trustForwardHeader: true
addAuthCookiesToResponse:
- session_id
- user_id
authResponseHeaders:
- X-Auth-SessionID
- X-Auth-UserID
- use: CustomErrorPage
realIPAuthentik:
- use: RedirectHTTP
- use: RealIP
header: X-Real-IP
from:
- "127.0.0.0/8"
- "192.168.0.0/16"
- "172.16.0.0/12"
recursive: true
- use: ForwardAuth
address: https://authentik.company
trustForwardHeader: true
testFakeRealIP:
- use: ModifyRequest
setHeaders:
CF-Connecting-IP: 127.0.0.1
- use: CloudflareRealIP

View File

@@ -9,8 +9,9 @@ import (
"net/http/httptest"
"net/url"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
gpHTTP "github.com/yusing/go-proxy/internal/http"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
//go:embed test_data/sample_headers.json
@@ -20,6 +21,9 @@ var testHeaders http.Header
const testHost = "example.com"
func init() {
if !common.IsTest {
return
}
tmp := map[string]string{}
err := json.Unmarshal(testHeadersRaw, &tmp)
if err != nil {
@@ -31,13 +35,15 @@ func init() {
}
}
type requestHeaderRecorder struct {
type requestRecorder struct {
parent http.RoundTripper
reqHeaders http.Header
headers http.Header
remoteAddr string
}
func (rt *requestHeaderRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
rt.reqHeaders = req.Header
func (rt *requestRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
rt.headers = req.Header
rt.remoteAddr = req.RemoteAddr
if rt.parent != nil {
return rt.parent.RoundTrip(req)
}
@@ -46,6 +52,7 @@ func (rt *requestHeaderRecorder) RoundTrip(req *http.Request) (*http.Response, e
Header: testHeaders,
Body: io.NopCloser(bytes.NewBufferString("OK")),
Request: req,
TLS: req.TLS,
}, nil
}
@@ -53,6 +60,7 @@ type TestResult struct {
RequestHeaders http.Header
ResponseHeaders http.Header
ResponseStatus int
RemoteAddr string
Data []byte
}
@@ -65,7 +73,7 @@ type testArgs struct {
func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, E.NestedError) {
var body io.Reader
var rt = new(requestHeaderRecorder)
var rr = new(requestRecorder)
var proxyURL *url.URL
var requestTarget string
var err error
@@ -98,17 +106,16 @@ func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, E.N
if err != nil {
return nil, E.From(err)
}
rt.parent = http.DefaultTransport
rr.parent = http.DefaultTransport
} else {
proxyURL, _ = url.Parse("https://" + testHost) // dummy url, no actual effect
}
rp := gpHTTP.NewReverseProxy(proxyURL, rt)
setOptErr := PatchReverseProxy(rp, map[string]OptionsRaw{
middleware.name: args.middlewareOpt,
})
rp := gphttp.NewReverseProxy(proxyURL, rr)
mid, setOptErr := middleware.WithOptionsClone(args.middlewareOpt)
if setOptErr != nil {
return nil, setOptErr
}
patchReverseProxy(middleware.name, rp, []*Middleware{mid})
rp.ServeHTTP(w, req)
resp := w.Result()
defer resp.Body.Close()
@@ -117,9 +124,10 @@ func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, E.N
return nil, E.From(err)
}
return &TestResult{
RequestHeaders: rt.reqHeaders,
RequestHeaders: rr.headers,
ResponseHeaders: resp.Header,
ResponseStatus: resp.StatusCode,
RemoteAddr: rr.remoteAddr,
Data: data,
}, nil
}

View File

@@ -0,0 +1,113 @@
package middleware
import (
"fmt"
"sync"
"time"
gphttp "github.com/yusing/go-proxy/internal/net/http"
U "github.com/yusing/go-proxy/internal/utils"
)
type Trace struct {
Time string `json:"time,omitempty"`
Caller string `json:"caller,omitempty"`
URL string `json:"url,omitempty"`
Message string `json:"msg"`
ReqHeaders map[string]string `json:"req_headers,omitempty"`
RespHeaders map[string]string `json:"resp_headers,omitempty"`
RespStatus int `json:"resp_status,omitempty"`
Additional map[string]any `json:"additional,omitempty"`
}
type Traces []*Trace
var traces = Traces{}
var tracesMu sync.Mutex
const MaxTraceNum = 100
func GetAllTrace() []*Trace {
return traces
}
func (tr *Trace) WithRequest(req *Request) *Trace {
if tr == nil {
return nil
}
tr.URL = req.RequestURI
tr.ReqHeaders = gphttp.HeaderToMap(req.Header)
return tr
}
func (tr *Trace) WithResponse(resp *Response) *Trace {
if tr == nil {
return nil
}
tr.URL = resp.Request.RequestURI
tr.ReqHeaders = gphttp.HeaderToMap(resp.Request.Header)
tr.RespHeaders = gphttp.HeaderToMap(resp.Header)
tr.RespStatus = resp.StatusCode
return tr
}
func (tr *Trace) With(what string, additional any) *Trace {
if tr == nil {
return nil
}
if tr.Additional == nil {
tr.Additional = map[string]any{}
}
tr.Additional[what] = additional
return tr
}
func (tr *Trace) WithError(err error) *Trace {
if tr == nil {
return nil
}
if tr.Additional == nil {
tr.Additional = map[string]any{}
}
tr.Additional["error"] = err.Error()
return tr
}
func (m *Middleware) EnableTrace() {
m.trace = true
for _, child := range m.children {
child.parent = m
child.EnableTrace()
}
}
func (m *Middleware) AddTracef(msg string, args ...any) *Trace {
if !m.trace {
return nil
}
return addTrace(&Trace{
Time: U.FormatTime(time.Now()),
Caller: m.Fullname(),
Message: fmt.Sprintf(msg, args...),
})
}
func (m *Middleware) AddTraceRequest(msg string, req *Request) *Trace {
return m.AddTracef("%s", msg).WithRequest(req)
}
func (m *Middleware) AddTraceResponse(msg string, resp *Response) *Trace {
return m.AddTracef("%s", msg).WithResponse(resp)
}
func addTrace(t *Trace) *Trace {
tracesMu.Lock()
defer tracesMu.Unlock()
if len(traces) > MaxTraceNum {
traces = traces[1:]
}
traces = append(traces, t)
return t
}

View File

@@ -0,0 +1,44 @@
package middleware
import (
"net"
)
const (
xForwardedFor = "X-Forwarded-For"
xForwardedMethod = "X-Forwarded-Method"
xForwardedHost = "X-Forwarded-Host"
xForwardedProto = "X-Forwarded-Proto"
xForwardedURI = "X-Forwarded-Uri"
xForwardedPort = "X-Forwarded-Port"
)
var SetXForwarded = &Middleware{
before: Rewrite(func(req *Request) {
req.Header.Del("Forwarded")
req.Header.Del(xForwardedFor)
req.Header.Del(xForwardedHost)
req.Header.Del(xForwardedProto)
clientIP, _, err := net.SplitHostPort(req.RemoteAddr)
if err == nil {
req.Header.Set(xForwardedFor, clientIP)
} else {
req.Header.Set(xForwardedFor, req.RemoteAddr)
}
req.Header.Set(xForwardedHost, req.Host)
if req.TLS == nil {
req.Header.Set(xForwardedProto, "http")
} else {
req.Header.Set(xForwardedProto, "https")
}
}),
}
var HideXForwarded = &Middleware{
before: Rewrite(func(req *Request) {
req.Header.Del("Forwarded")
req.Header.Del(xForwardedFor)
req.Header.Del(xForwardedHost)
req.Header.Del(xForwardedProto)
}),
}

View File

@@ -21,9 +21,12 @@ import (
"net/textproto"
"net/url"
"strings"
"sync"
"github.com/sirupsen/logrus"
"golang.org/x/net/http/httpguts"
U "github.com/yusing/go-proxy/internal/utils"
)
// A ProxyRequest contains a request to be rewritten by a [ReverseProxy].
@@ -58,39 +61,6 @@ type ProxyRequest struct {
// r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
// r.SetXForwarded()
// }
func (r *ProxyRequest) SetXForwarded() {
clientIP, _, err := net.SplitHostPort(r.In.RemoteAddr)
if err == nil {
r.Out.Header.Set("X-Forwarded-For", clientIP)
} else {
r.Out.Header.Del("X-Forwarded-For")
}
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
if r.In.TLS == nil {
r.Out.Header.Set("X-Forwarded-Proto", "http")
} else {
r.Out.Header.Set("X-Forwarded-Proto", "https")
}
}
func (r *ProxyRequest) AddXForwarded() {
clientIP, _, err := net.SplitHostPort(r.In.RemoteAddr)
if err == nil {
prior := r.Out.Header["X-Forwarded-For"]
if len(prior) > 0 {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
r.Out.Header.Set("X-Forwarded-For", clientIP)
} else {
r.Out.Header.Del("X-Forwarded-For")
}
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
if r.In.TLS == nil {
r.Out.Header.Set("X-Forwarded-Proto", "http")
} else {
r.Out.Header.Set("X-Forwarded-Proto", "https")
}
}
// ReverseProxy is an HTTP Handler that takes an incoming request and
// sends it to another server, proxying the response back to the
@@ -99,28 +69,35 @@ func (r *ProxyRequest) AddXForwarded() {
// 1xx responses are forwarded to the client if the underlying
// transport supports ClientTrace.Got1xxResponse.
type ReverseProxy struct {
// Rewrite must be a function which modifies
// Director is a function which modifies
// the request into a new request to be sent
// using Transport. Its response is then copied
// back to the original client unmodified.
// Rewrite must not access the provided ProxyRequest
// or its contents after returning.
// Director must not access the provided Request
// after returning.
//
// The Forwarded, X-Forwarded, X-Forwarded-Host,
// and X-Forwarded-Proto headers are removed from the
// outbound request before Rewrite is called. See also
// the ProxyRequest.SetXForwarded method.
// By default, the X-Forwarded-For header is set to the
// value of the client IP address. If an X-Forwarded-For
// header already exists, the client IP is appended to the
// existing values. As a special case, if the header
// exists in the Request.Header map but has a nil value
// (such as when set by the Director func), the X-Forwarded-For
// header is not modified.
//
// Unparsable query parameters are removed from the
// outbound request before Rewrite is called.
// The Rewrite function may copy the inbound URL's
// RawQuery to the outbound URL to preserve the original
// parameter string. Note that this can lead to security
// issues if the proxy's interpretation of query parameters
// does not match that of the downstream server.
// To prevent IP spoofing, be sure to delete any pre-existing
// X-Forwarded-For header coming from the client or
// an untrusted proxy.
//
// Hop-by-hop headers are removed from the request after
// Director returns, which can remove headers added by
// Director. Use a Rewrite function instead to ensure
// modifications to the request are preserved.
//
// Unparsable query parameters are removed from the outbound
// request if Request.Form is set after Director returns.
//
// At most one of Rewrite or Director may be set.
Rewrite func(*ProxyRequest)
Director func(*http.Request)
// The transport used to perform proxy requests.
// If nil, http.DefaultTransport is used.
@@ -140,13 +117,6 @@ type ReverseProxy struct {
ServeHTTP http.HandlerFunc
}
// A BufferPool is an interface for getting and returning temporary
// byte slices for use by [io.CopyBuffer].
type BufferPool interface {
Get() []byte
Put([]byte)
}
func singleJoiningSlash(a, b string) string {
aslash := strings.HasSuffix(a, "/")
bslash := strings.HasPrefix(b, "/")
@@ -203,10 +173,14 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) {
//
func NewReverseProxy(target *url.URL, transport http.RoundTripper) *ReverseProxy {
if transport == nil {
panic("nil transport")
}
rp := &ReverseProxy{
Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
}, Transport: transport,
Director: func(req *http.Request) {
rewriteRequestURL(req, target)
},
Transport: transport,
}
rp.ServeHTTP = rp.serveHTTP
return rp
@@ -224,6 +198,14 @@ func rewriteRequestURL(req *http.Request, target *url.URL) {
}
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
// Hop-by-hop headers. These are removed when sent to the backend.
// As of RFC 7230, hop-by-hop headers are required to appear in the
// Connection header field. These are the headers defined by the
@@ -241,16 +223,14 @@ var hopHeaders = []string{
"Upgrade",
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
dst.Add(k, v)
}
}
}
func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, r *http.Request, err error, writeHeader bool) {
logger.Errorf("http proxy to %s failed: %s", r.URL.String(), err)
switch {
case errors.Is(err, context.Canceled),
errors.Is(err, io.EOF):
logger.Debugf("http proxy to %s error: %s", r.URL.String(), err)
default:
logger.Errorf("http proxy to %s error: %s", r.URL.String(), err)
}
if writeHeader {
rw.WriteHeader(http.StatusBadGateway)
}
@@ -316,6 +296,7 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate
}
p.Director(outreq)
outreq.Close = false
reqUpType := UpgradeType(outreq.Header)
@@ -342,17 +323,27 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
outreq.Header.Set("Upgrade", reqUpType)
}
outreq.Header.Del("Forwarded")
// outreq.Header.Del("X-Forwarded-For")
// outreq.Header.Del("X-Forwarded-Host")
// outreq.Header.Del("X-Forwarded-Proto")
pr := &ProxyRequest{
In: req,
Out: outreq,
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
// If we aren't the first proxy retain prior
// X-Forwarded-For information as a comma+space
// separated list and fold multiple headers into one.
prior, ok := outreq.Header["X-Forwarded-For"]
omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
if len(prior) > 0 {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
if !omit {
outreq.Header.Set("X-Forwarded-For", clientIP)
}
}
p.Rewrite(pr)
outreq = pr.Out
if req.TLS == nil {
outreq.Header.Set("X-Forwarded-Proto", "http")
outreq.Header.Set("X-Forwarded-Scheme", "http")
} else {
outreq.Header.Set("X-Forwarded-Proto", "https")
outreq.Header.Set("X-Forwarded-Scheme", "https")
}
outreq.Header.Set("X-Forwarded-Host", req.Host)
if _, ok := outreq.Header["User-Agent"]; !ok {
// If the outbound request doesn't have a User-Agent header set,
@@ -360,15 +351,21 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
outreq.Header.Set("User-Agent", "")
}
var (
roundTripMutex sync.Mutex
roundTripDone bool
)
trace := &httptrace.ClientTrace{
Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
h := rw.Header()
// copyHeader(h, http.Header(header))
for k, vv := range header {
for _, v := range vv {
h.Add(k, v)
}
roundTripMutex.Lock()
defer roundTripMutex.Unlock()
if roundTripDone {
// If RoundTrip has returned, don't try to further modify
// the ResponseWriter's header map.
return nil
}
h := rw.Header()
copyHeader(h, http.Header(header))
rw.WriteHeader(code)
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
@@ -379,6 +376,9 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
outreq = outreq.WithContext(httptrace.WithClientTrace(outreq.Context(), trace))
res, err := transport.RoundTrip(outreq)
roundTripMutex.Lock()
roundTripDone = true
roundTripMutex.Unlock()
if err != nil {
p.errorHandler(rw, outreq, err, false)
errMsg := err.Error()
@@ -389,7 +389,7 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
ProtoMajor: outreq.ProtoMajor,
ProtoMinor: outreq.ProtoMinor,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader([]byte(errMsg))),
Body: io.NopCloser(bytes.NewReader([]byte("Origin server is not reachable."))),
Request: outreq,
ContentLength: int64(len(errMsg)),
TLS: outreq.TLS,
@@ -405,6 +405,8 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
return
}
RemoveHopByHopHeaders(res.Header)
if !p.modifyResponse(rw, res, outreq) {
return
}
@@ -424,9 +426,11 @@ func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(res.StatusCode)
_, err = io.Copy(rw, res.Body)
err = U.Copy2(req.Context(), rw, res.Body)
if err != nil {
p.errorHandler(rw, req, err, true)
if !errors.Is(err, context.Canceled) {
p.errorHandler(rw, req, err, true)
}
res.Body.Close()
return
}
@@ -531,17 +535,9 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R
p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err), true)
return
}
errc := make(chan error, 1)
go func() {
_, err := io.Copy(conn, backConn)
errc <- err
}()
go func() {
_, err := io.Copy(backConn, conn)
errc <- err
}()
<-errc
bdp := U.NewBidirectionalPipe(req.Context(), conn, backConn)
bdp.Start()
}
func IsPrint(s string) bool {

View File

@@ -7,8 +7,8 @@ import (
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
T "github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/types"
)
type (
@@ -28,6 +28,7 @@ type (
StopSignal T.Signal
DockerHost string
ContainerName string
ContainerID string
ContainerRunning bool
}
StreamEntry struct {
@@ -42,10 +43,12 @@ func (rp *ReverseProxyEntry) UseIdleWatcher() bool {
return rp.IdleTimeout > 0 && rp.DockerHost != ""
}
func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
if !m.FillMissingFields() {
return nil, E.Missing("fields")
}
func (rp *ReverseProxyEntry) IsDocker() bool {
return rp.DockerHost != ""
}
func ValidateEntry(m *types.RawEntry) (any, E.NestedError) {
m.FillMissingFields()
scheme, err := T.NewScheme(m.Scheme)
if err.HasError() {
@@ -65,7 +68,7 @@ func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
return entry, nil
}
func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
func validateRPEntry(m *types.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
var stopTimeOut time.Duration
host, err := T.ValidateHost(m.Host)
@@ -115,11 +118,12 @@ func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry
StopSignal: stopSignal,
DockerHost: m.DockerHost,
ContainerName: m.ContainerName,
ContainerID: m.ContainerID,
ContainerRunning: m.Running,
}
}
func validateStreamEntry(m *M.RawEntry, b E.Builder) *StreamEntry {
func validateStreamEntry(m *types.RawEntry, b E.Builder) *StreamEntry {
host, err := T.ValidateHost(m.Host)
b.Add(err)

View File

@@ -33,9 +33,8 @@ func (p Port) String() string {
}
const (
MinPort = 0
MaxPort = 65535
ErrPort = Port(-1)
NoPort = Port(-1)
ZeroPort = Port(0)
MinPort = 0
MaxPort = 65535
ErrPort = Port(-1)
NoPort = Port(0)
)

View File

@@ -12,7 +12,7 @@ type StreamPort struct {
ProxyPort Port `json:"proxy"`
}
func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
func ValidateStreamPort(p string) (_ StreamPort, err E.NestedError) {
split := strings.Split(p, ":")
switch len(split) {
@@ -21,24 +21,26 @@ func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
case 2:
break
default:
return ErrStreamPort, E.Invalid("stream port", p).With("too many colons")
err = E.Invalid("stream port", p).With("too many colons")
return
}
listeningPort, err := ValidatePort(split[0])
if err != nil {
return ErrStreamPort, err.Subject("listening port")
err = err.Subject("listening port")
return
}
proxyPort, err := ValidatePort(split[1])
if err.Is(E.ErrOutOfRange) {
return ErrStreamPort, err.Subject("proxy port")
} else if proxyPort == 0 {
return ErrStreamPort, E.Invalid("proxy port", p)
err = err.Subject("proxy port")
return
} else if err != nil {
proxyPort, err = parseNameToPort(split[1])
if err != nil {
return ErrStreamPort, E.Invalid("proxy port", proxyPort)
err = E.Invalid("proxy port", proxyPort)
return
}
}
@@ -52,5 +54,3 @@ func parseNameToPort(name string) (Port, E.NestedError) {
}
return Port(port), nil
}
var ErrStreamPort = StreamPort{ErrPort, ErrPort}

View File

@@ -9,29 +9,31 @@ import (
"github.com/sirupsen/logrus"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
W "github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
)
type DockerProvider struct {
dockerHost, hostname string
ExplicitOnly bool
name, dockerHost, hostname string
ExplicitOnly bool
}
var AliasRefRegex = regexp.MustCompile(`#\d+`)
var AliasRefRegexOld = regexp.MustCompile(`\$\d+`)
func DockerProviderImpl(dockerHost string, explicitOnly bool) (ProviderImpl, E.NestedError) {
func DockerProviderImpl(name, dockerHost string, explicitOnly bool) (ProviderImpl, E.NestedError) {
hostname, err := D.ParseDockerHostname(dockerHost)
if err.HasError() {
return nil, err
}
return &DockerProvider{dockerHost, hostname, explicitOnly}, nil
return &DockerProvider{name, dockerHost, hostname, explicitOnly}, nil
}
func (p *DockerProvider) String() string {
return fmt.Sprintf("docker:%s", p.dockerHost)
return fmt.Sprintf("docker: %s", p.name)
}
func (p *DockerProvider) NewWatcher() W.Watcher {
@@ -40,7 +42,7 @@ func (p *DockerProvider) NewWatcher() W.Watcher {
func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
routes = R.NewRoutes()
entries := M.NewProxyEntries()
entries := types.NewProxyEntries()
info, err := D.GetClientInfo(p.dockerHost, true)
if err.HasError() {
@@ -63,12 +65,12 @@ func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
// there may be some valid entries in `en`
dups := entries.MergeFrom(newEntries)
// add the duplicate proxy entries to the error
dups.RangeAll(func(k string, v *M.RawEntry) {
dups.RangeAll(func(k string, v *types.RawEntry) {
errors.Addf("duplicate alias %s", k)
})
}
entries.RangeAll(func(_ string, e *M.RawEntry) {
entries.RangeAll(func(_ string, e *types.RawEntry) {
e.DockerHost = p.dockerHost
})
@@ -78,18 +80,61 @@ func (p *DockerProvider) LoadRoutesImpl() (routes R.Routes, err E.NestedError) {
return routes, errors.Build()
}
func (p *DockerProvider) shouldIgnore(container D.Container) bool {
return container.IsExcluded ||
!container.IsExplicit && p.ExplicitOnly ||
!container.IsExplicit && container.IsDatabase ||
strings.HasSuffix(container.ContainerName, "-old")
}
func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResult) {
switch event.Action {
case events.ActionContainerStart, events.ActionContainerStop:
break
default:
return
}
b := E.NewBuilder("event %s error", event)
defer b.To(&res.err)
routes.RangeAll(func(k string, v R.Route) {
if v.Entry().ContainerName == event.ActorName {
if v.Entry().ContainerID == event.ActorID ||
v.Entry().ContainerName == event.ActorName {
b.Add(v.Stop())
routes.Delete(k)
res.nRemoved++
}
})
if res.nRemoved == 0 { // id & container name changed
// load all routes (rescan)
routesNew, err := p.LoadRoutesImpl()
routesOld := routes
if routesNew.Size() == 0 {
b.Add(E.FailWith("rescan routes", err))
return
}
routesNew.Range(func(k string, v R.Route) bool {
if !routesOld.Has(k) {
routesOld.Store(k, v)
b.Add(v.Start())
res.nAdded++
return false
}
return true
})
routesOld.Range(func(k string, v R.Route) bool {
if !routesNew.Has(k) {
b.Add(v.Stop())
routesOld.Delete(k)
res.nRemoved++
return false
}
return true
})
return
}
client, err := D.ConnectClient(p.dockerHost)
if err.HasError() {
b.Add(E.FailWith("connect to docker", err))
@@ -101,10 +146,15 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
b.Add(E.FailWith("inspect container", err))
return
}
if p.shouldIgnore(cont) {
return
}
entries, err := p.entriesFromContainerLabels(cont)
b.Add(err)
entries.RangeAll(func(alias string, entry *M.RawEntry) {
entries.RangeAll(func(alias string, entry *types.RawEntry) {
if routes.Has(alias) {
b.Add(E.Duplicated("alias", alias))
} else {
@@ -123,17 +173,16 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
// Returns a list of proxy entries for a container.
// Always non-nil
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (entries M.RawEntries, _ E.NestedError) {
entries = M.NewProxyEntries()
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (entries types.RawEntries, _ E.NestedError) {
entries = types.NewProxyEntries()
if container.IsExcluded ||
!container.IsExplicit && p.ExplicitOnly {
if p.shouldIgnore(container) {
return
}
// init entries map for all aliases
for _, a := range container.Aliases {
entries.Store(a, &M.RawEntry{
entries.Store(a, &types.RawEntry{
Alias: a,
Host: p.hostname,
ProxyProperties: container.ProxyProperties,
@@ -146,14 +195,14 @@ func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (entr
}
// remove all entries that failed to fill in missing fields
entries.RemoveAll(func(re *M.RawEntry) bool {
return !re.FillMissingFields()
entries.RangeAll(func(_ string, re *types.RawEntry) {
re.FillMissingFields()
})
return entries, errors.Build().Subject(container.ContainerName)
}
func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries, key, val string) (res E.NestedError) {
func (p *DockerProvider) applyLabel(container D.Container, entries types.RawEntries, key, val string) (res E.NestedError) {
b := E.NewBuilder("errors in label %s", key)
defer b.To(&res)
@@ -180,7 +229,7 @@ func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries,
}
if lbl.Target == D.WildcardAlias {
// apply label for all aliases
entries.RangeAll(func(a string, e *M.RawEntry) {
entries.RangeAll(func(a string, e *types.RawEntry) {
if err = D.ApplyLabel(e, lbl); err.HasError() {
b.Add(err.Subjectf("alias %s", lbl.Target))
}

View File

@@ -235,73 +235,75 @@ func TestExplicitExclude(t *testing.T) {
ExpectFalse(t, ok)
}
func TestImplicitExclude(t *testing.T) {
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a",
"proxy.a.no_tls_verify": "true",
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
//! Now nothing will be implicit excluded
//
// func TestImplicitExclude(t *testing.T) {
// var p DockerProvider
// entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
// Names: dummyNames,
// Labels: map[string]string{
// D.LabelAliases: "a",
// "proxy.a.no_tls_verify": "true",
// },
// State: "running",
// }, ""))
// ExpectNoError(t, err.Error())
_, ok := entries.Load("a")
ExpectFalse(t, ok)
}
// _, ok := entries.Load("a")
// ExpectFalse(t, ok)
// }
func TestImplicitExcludeNoExposedPort(t *testing.T) {
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Image: "redis",
Names: []string{"redis"},
Ports: []types.Port{
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
// func TestImplicitExcludeNoExposedPort(t *testing.T) {
// var p DockerProvider
// entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// State: "running",
// }, ""))
// ExpectNoError(t, err.Error())
_, ok := entries.Load("redis")
ExpectFalse(t, ok)
}
// _, ok := entries.Load("redis")
// ExpectFalse(t, ok)
// }
func TestNotExcludeSpecifiedPort(t *testing.T) {
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Image: "redis",
Names: []string{"redis"},
Ports: []types.Port{
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379", // but specified in label
},
}, ""))
ExpectNoError(t, err.Error())
// func TestNotExcludeSpecifiedPort(t *testing.T) {
// var p DockerProvider
// entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// Labels: map[string]string{
// "proxy.redis.port": "6379:6379", // but specified in label
// },
// }, ""))
// ExpectNoError(t, err.Error())
_, ok := entries.Load("redis")
ExpectTrue(t, ok)
}
// _, ok := entries.Load("redis")
// ExpectTrue(t, ok)
// }
func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
var p DockerProvider
cont := &types.Container{
Image: "redis",
Names: []string{"redis"},
Ports: []types.Port{
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379",
},
}
cont.HostConfig.NetworkMode = "host"
// func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
// var p DockerProvider
// cont := &types.Container{
// Image: "redis",
// Names: []string{"redis"},
// Ports: []types.Port{
// {Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
// },
// Labels: map[string]string{
// "proxy.redis.port": "6379:6379",
// },
// }
// cont.HostConfig.NetworkMode = "host"
entries, err := p.entriesFromContainerLabels(D.FromDocker(cont, ""))
ExpectNoError(t, err.Error())
// entries, err := p.entriesFromContainerLabels(D.FromDocker(cont, ""))
// ExpectNoError(t, err.Error())
_, ok := entries.Load("redis")
ExpectTrue(t, ok)
}
// _, ok := entries.Load("redis")
// ExpectTrue(t, ok)
// }

View File

@@ -7,8 +7,8 @@ import (
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
W "github.com/yusing/go-proxy/internal/watcher"
)
@@ -71,7 +71,7 @@ func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
b := E.NewBuilder("file %q validation failure", p.fileName)
defer b.To(&res)
entries := M.NewProxyEntries()
entries := types.NewProxyEntries()
data, err := E.Check(os.ReadFile(p.path))
if err.HasError() {
@@ -79,12 +79,6 @@ func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
return
}
if !common.NoSchemaValidation {
if err = Validate(data); err.HasError() {
b.Add(err)
return
}
}
if err = entries.UnmarshalFromYAML(data); err.HasError() {
b.Add(err)
return

View File

@@ -31,8 +31,13 @@ type (
OnEvent(event W.Event, routes R.Routes) EventResult
String() string
}
ProviderType string
EventResult struct {
ProviderType string
ProviderStats struct {
NumRPs int `json:"num_reverse_proxies"`
NumStreams int `json:"num_streams"`
Type ProviderType `json:"type"`
}
EventResult struct {
nRemoved int
nAdded int
err E.NestedError
@@ -72,14 +77,9 @@ func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.Neste
if name == "" {
return nil, E.Invalid("provider name", "empty")
}
explicitOnly := false
if name[len(name)-1] == '!' {
explicitOnly = true
name = name[:len(name)-1]
}
p = newProvider(name, ProviderTypeDocker)
p.ProviderImpl, err = DockerProviderImpl(dockerHost, explicitOnly)
p.ProviderImpl, err = DockerProviderImpl(name, dockerHost, p.IsExplicitOnly())
if err != nil {
return nil, err
}
@@ -87,6 +87,10 @@ func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.Neste
return
}
func (p *Provider) IsExplicitOnly() bool {
return p.name[len(p.name)-1] == '!'
}
func (p *Provider) GetName() string {
return p.name
}
@@ -164,6 +168,27 @@ func (p *Provider) LoadRoutes() E.NestedError {
return E.FailWith("loading routes", err)
}
func (p *Provider) Statistics() ProviderStats {
numRPs := 0
numStreams := 0
p.routes.RangeAll(func(_ string, r R.Route) {
if !r.Started() {
return
}
switch r.Type() {
case R.RouteTypeReverseProxy:
numRPs++
case R.RouteTypeStream:
numStreams++
}
})
return ProviderStats{
NumRPs: numRPs,
NumStreams: numStreams,
Type: p.t,
}
}
func (p *Provider) watchEvents() {
p.watcherCtx, p.watcherCancel = context.WithCancel(context.Background())
events, errs := p.watcher.Events(p.watcherCtx)
@@ -176,7 +201,9 @@ func (p *Provider) watchEvents() {
case event := <-events:
res := p.OnEvent(event, p.routes)
l.Infof("%s event %q", event.Type, event)
l.Infof("%d route added, %d routes removed", res.nAdded, res.nRemoved)
if res.nAdded > 0 || res.nRemoved > 0 {
l.Infof("%d route added, %d routes removed", res.nAdded, res.nRemoved)
}
if res.err.HasError() {
l.Error(res.err)
}

View File

@@ -10,13 +10,12 @@ import (
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/api/v1/error_page"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/http"
. "github.com/yusing/go-proxy/internal/net/http"
"github.com/yusing/go-proxy/internal/net/http/middleware"
P "github.com/yusing/go-proxy/internal/proxy"
PT "github.com/yusing/go-proxy/internal/proxy/fields"
"github.com/yusing/go-proxy/internal/route/middleware"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
@@ -27,25 +26,30 @@ type (
PathPatterns PT.PathPatterns `json:"path_patterns"`
entry *P.ReverseProxyEntry
mux *http.ServeMux
handler *ReverseProxy
regIdleWatcher func() E.NestedError
unregIdleWatcher func()
handler http.Handler
rp *ReverseProxy
}
URL url.URL
SubdomainKey = PT.Alias
ReverseProxyHandler struct {
*ReverseProxy
}
)
var (
findMuxFunc = findMuxAnyDomain
httpRoutes = F.NewMapOf[SubdomainKey, *HTTPRoute]()
httpRoutes = F.NewMapOf[string, *HTTPRoute]()
httpRoutesMu sync.Mutex
globalMux = http.NewServeMux() // TODO: support regex subdomain matching
)
func (rp ReverseProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rp.ReverseProxy.ServeHTTP(w, r)
}
func SetFindMuxDomains(domains []string) {
if len(domains) == 0 {
findMuxFunc = findMuxAnyDomain
@@ -56,60 +60,31 @@ func SetFindMuxDomains(domains []string) {
func NewHTTPRoute(entry *P.ReverseProxyEntry) (*HTTPRoute, E.NestedError) {
var trans *http.Transport
var regIdleWatcher func() E.NestedError
var unregIdleWatcher func()
if entry.NoTLSVerify {
trans = common.DefaultTransportNoTLS.Clone()
trans = DefaultTransportNoTLS.Clone()
} else {
trans = common.DefaultTransport.Clone()
trans = DefaultTransport.Clone()
}
rp := NewReverseProxy(entry.URL, trans)
if len(entry.Middlewares) > 0 {
err := middleware.PatchReverseProxy(rp, entry.Middlewares)
err := middleware.PatchReverseProxy(string(entry.Alias), rp, entry.Middlewares)
if err != nil {
return nil, err
}
}
if entry.UseIdleWatcher() {
// allow time for response header up to `WakeTimeout`
if entry.WakeTimeout > trans.ResponseHeaderTimeout {
trans.ResponseHeaderTimeout = entry.WakeTimeout
}
regIdleWatcher = func() E.NestedError {
watcher, err := idlewatcher.Register(entry)
if err.HasError() {
return err
}
// patch round-tripper
rp.Transport = watcher.PatchRoundTripper(trans)
return nil
}
unregIdleWatcher = func() {
idlewatcher.Unregister(entry.ContainerName)
rp.Transport = trans
}
}
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
_, exists := httpRoutes.Load(entry.Alias)
if exists {
return nil, E.Duplicated("HTTPRoute alias", entry.Alias)
}
r := &HTTPRoute{
Alias: entry.Alias,
TargetURL: (*URL)(entry.URL),
PathPatterns: entry.PathPatterns,
entry: entry,
handler: rp,
regIdleWatcher: regIdleWatcher,
unregIdleWatcher: unregIdleWatcher,
Alias: entry.Alias,
TargetURL: (*URL)(entry.URL),
PathPatterns: entry.PathPatterns,
entry: entry,
rp: rp,
}
return r, nil
}
@@ -119,45 +94,55 @@ func (r *HTTPRoute) String() string {
}
func (r *HTTPRoute) Start() E.NestedError {
if r.mux != nil {
if r.handler != nil {
return nil
}
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
if r.regIdleWatcher != nil {
if err := r.regIdleWatcher(); err.HasError() {
r.unregIdleWatcher = nil
if r.entry.UseIdleWatcher() {
watcher, err := idlewatcher.Register(r.entry)
if err != nil {
return err
}
r.handler = idlewatcher.NewWaker(watcher, r.rp)
} else if r.entry.URL.Port() == "0" ||
r.entry.IsDocker() && !r.entry.ContainerRunning {
return nil
} else if len(r.PathPatterns) == 1 && r.PathPatterns[0] == "/" {
r.handler = ReverseProxyHandler{r.rp}
} else {
mux := http.NewServeMux()
for _, p := range r.PathPatterns {
mux.HandleFunc(string(p), r.rp.ServeHTTP)
}
r.handler = mux
}
r.mux = http.NewServeMux()
for _, p := range r.PathPatterns {
r.mux.HandleFunc(string(p), r.handler.ServeHTTP)
}
httpRoutes.Store(r.Alias, r)
httpRoutes.Store(string(r.Alias), r)
return nil
}
func (r *HTTPRoute) Stop() E.NestedError {
if r.mux == nil {
return nil
func (r *HTTPRoute) Stop() (_ E.NestedError) {
if r.handler == nil {
return
}
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
if r.unregIdleWatcher != nil {
r.unregIdleWatcher()
r.unregIdleWatcher = nil
if waker, ok := r.handler.(*idlewatcher.Waker); ok {
waker.Unregister()
}
r.mux = nil
httpRoutes.Delete(r.Alias)
return nil
r.handler = nil
httpRoutes.Delete(string(r.Alias))
return
}
func (r *HTTPRoute) Started() bool {
return r.handler != nil
}
func (u *URL) String() string {
@@ -189,21 +174,21 @@ func ProxyHandler(w http.ResponseWriter, r *http.Request) {
mux.ServeHTTP(w, r)
}
func findMuxAnyDomain(host string) (*http.ServeMux, error) {
func findMuxAnyDomain(host string) (http.Handler, error) {
hostSplit := strings.Split(host, ".")
n := len(hostSplit)
if n <= 2 {
return nil, fmt.Errorf("missing subdomain in url")
}
sd := strings.Join(hostSplit[:n-2], ".")
if r, ok := httpRoutes.Load(PT.Alias(sd)); ok {
return r.mux, nil
if r, ok := httpRoutes.Load(sd); ok {
return r.handler, nil
}
return nil, fmt.Errorf("no such route: %s", sd)
}
func findMuxByDomains(domains []string) func(host string) (*http.ServeMux, error) {
return func(host string) (*http.ServeMux, error) {
func findMuxByDomains(domains []string) func(host string) (http.Handler, error) {
return func(host string) (http.Handler, error) {
var subdomain string
for _, domain := range domains {
@@ -218,8 +203,8 @@ func findMuxByDomains(domains []string) func(host string) (*http.ServeMux, error
if len(subdomain) == len(host) { // not matched
return nil, fmt.Errorf("%s does not match any base domain", host)
}
if r, ok := httpRoutes.Load(PT.Alias(subdomain)); ok {
return r.mux, nil
if r, ok := httpRoutes.Load(subdomain); ok {
return r.handler, nil
}
return nil, fmt.Errorf("no such route: %s", subdomain)
}

View File

@@ -1,145 +0,0 @@
package middleware
import (
"net/http"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
gpHTTP "github.com/yusing/go-proxy/internal/http"
)
type (
Error = E.NestedError
ReverseProxy = gpHTTP.ReverseProxy
ProxyRequest = gpHTTP.ProxyRequest
Request = http.Request
Response = http.Response
ResponseWriter = http.ResponseWriter
Header = http.Header
Cookie = http.Cookie
BeforeFunc func(next http.Handler, w ResponseWriter, r *Request)
RewriteFunc func(req *ProxyRequest)
ModifyResponseFunc func(resp *Response) error
CloneWithOptFunc func(opts OptionsRaw, rp *ReverseProxy) (*Middleware, E.NestedError)
OptionsRaw = map[string]any
Options any
Middleware struct {
name string
before BeforeFunc // runs before ReverseProxy.ServeHTTP
rewrite RewriteFunc // runs after ReverseProxy.Rewrite
modifyResponse ModifyResponseFunc // runs after ReverseProxy.ModifyResponse
transport http.RoundTripper
withOptions CloneWithOptFunc
labelParserMap D.ValueParserMap
impl any
}
)
func (m *Middleware) Name() string {
return m.name
}
func (m *Middleware) String() string {
return m.name
}
func (m *Middleware) WithOptionsClone(optsRaw OptionsRaw, rp *ReverseProxy) (*Middleware, E.NestedError) {
if len(optsRaw) != 0 && m.withOptions != nil {
if mWithOpt, err := m.withOptions(optsRaw, rp); err != nil {
return nil, err
} else {
return mWithOpt, nil
}
}
// WithOptionsClone is called only once
// set withOptions and labelParser will not be used after that
return &Middleware{m.name, m.before, m.rewrite, m.modifyResponse, m.transport, nil, nil, m.impl}, nil
}
// TODO: check conflict or duplicates
func PatchReverseProxy(rp *ReverseProxy, middlewares map[string]OptionsRaw) (res E.NestedError) {
befores := make([]BeforeFunc, 0, len(middlewares))
rewrites := make([]RewriteFunc, 0, len(middlewares))
modResps := make([]ModifyResponseFunc, 0, len(middlewares))
invalidM := E.NewBuilder("invalid middlewares")
invalidOpts := E.NewBuilder("invalid options")
defer func() {
invalidM.Add(invalidOpts.Build())
invalidM.To(&res)
}()
for name, opts := range middlewares {
m, ok := Get(name)
if !ok {
invalidM.Addf("%s", name)
continue
}
m, err := m.WithOptionsClone(opts, rp)
if err != nil {
invalidOpts.Add(err.Subject(name))
continue
}
if m.before != nil {
befores = append(befores, m.before)
}
if m.rewrite != nil {
rewrites = append(rewrites, m.rewrite)
}
if m.modifyResponse != nil {
modResps = append(modResps, m.modifyResponse)
}
}
if invalidM.HasError() {
return
}
origServeHTTP := rp.ServeHTTP
for i, before := range befores {
if i < len(befores)-1 {
rp.ServeHTTP = func(w ResponseWriter, r *Request) {
before(rp.ServeHTTP, w, r)
}
} else {
rp.ServeHTTP = func(w ResponseWriter, r *Request) {
before(origServeHTTP, w, r)
}
}
}
if len(rewrites) > 0 {
if rp.Rewrite != nil {
rewrites = append([]RewriteFunc{rp.Rewrite}, rewrites...)
}
rp.Rewrite = func(req *ProxyRequest) {
for _, rewrite := range rewrites {
rewrite(req)
}
}
}
if len(modResps) > 0 {
if rp.ModifyResponse != nil {
modResps = append([]ModifyResponseFunc{rp.ModifyResponse}, modResps...)
}
rp.ModifyResponse = func(res *Response) error {
b := E.NewBuilder("errors in middleware ModifyResponse")
for _, mr := range modResps {
b.AddE(mr(res))
}
return b.Build().Error()
}
}
return
}

View File

@@ -1,45 +0,0 @@
package middleware
import (
"fmt"
"strings"
D "github.com/yusing/go-proxy/internal/docker"
)
var middlewares map[string]*Middleware
func Get(name string) (middleware *Middleware, ok bool) {
middleware, ok = middlewares[name]
return
}
// initialize middleware names and label parsers
func init() {
middlewares = map[string]*Middleware{
"set_x_forwarded": SetXForwarded,
"add_x_forwarded": AddXForwarded,
"redirect_http": RedirectHTTP,
"forward_auth": ForwardAuth.m,
"modify_response": ModifyResponse.m,
"modify_request": ModifyRequest.m,
"error_page": CustomErrorPage,
"custom_error_page": CustomErrorPage,
}
names := make(map[*Middleware][]string)
for name, m := range middlewares {
names[m] = append(names[m], name)
// register middleware name to docker label parsr
// in order to parse middleware_name.option=value into correct type
if m.labelParserMap != nil {
D.RegisterNamespace(name, m.labelParserMap)
}
}
for m, names := range names {
if len(names) > 1 {
m.name = fmt.Sprintf("%s (a.k.a. %s)", names[0], strings.Join(names[1:], ", "))
} else {
m.name = names[0]
}
}
}

View File

@@ -1,58 +0,0 @@
package middleware
import (
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
)
type (
modifyRequest struct {
*modifyRequestOpts
m *Middleware
}
// order: set_headers -> add_headers -> hide_headers
modifyRequestOpts struct {
SetHeaders map[string]string
AddHeaders map[string]string
HideHeaders []string
}
)
var ModifyRequest = newModifyRequest()
func newModifyRequest() (mr *modifyRequest) {
mr = new(modifyRequest)
mr.m = new(Middleware)
mr.m.labelParserMap = D.ValueParserMap{
"set_headers": D.YamlLikeMappingParser(true),
"add_headers": D.YamlLikeMappingParser(true),
"hide_headers": D.YamlStringListParser,
}
mr.m.withOptions = func(optsRaw OptionsRaw, rp *ReverseProxy) (*Middleware, E.NestedError) {
mrWithOpts := new(modifyRequest)
mrWithOpts.m = &Middleware{
impl: mrWithOpts,
rewrite: mrWithOpts.modifyRequest,
}
mrWithOpts.modifyRequestOpts = new(modifyRequestOpts)
err := U.Deserialize(optsRaw, mrWithOpts.modifyRequestOpts)
if err != nil {
return nil, E.FailWith("set options", err)
}
return mrWithOpts.m, nil
}
return
}
func (mr *modifyRequest) modifyRequest(req *ProxyRequest) {
for k, v := range mr.SetHeaders {
req.Out.Header.Set(k, v)
}
for k, v := range mr.AddHeaders {
req.Out.Header.Add(k, v)
}
for _, k := range mr.HideHeaders {
req.Out.Header.Del(k)
}
}

View File

@@ -1,61 +0,0 @@
package middleware
import (
"net/http"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
)
type (
modifyResponse struct {
*modifyResponseOpts
m *Middleware
}
// order: set_headers -> add_headers -> hide_headers
modifyResponseOpts struct {
SetHeaders map[string]string
AddHeaders map[string]string
HideHeaders []string
}
)
var ModifyResponse = newModifyResponse()
func newModifyResponse() (mr *modifyResponse) {
mr = new(modifyResponse)
mr.m = new(Middleware)
mr.m.labelParserMap = D.ValueParserMap{
"set_headers": D.YamlLikeMappingParser(true),
"add_headers": D.YamlLikeMappingParser(true),
"hide_headers": D.YamlStringListParser,
}
mr.m.withOptions = func(optsRaw OptionsRaw, rp *ReverseProxy) (*Middleware, E.NestedError) {
mrWithOpts := new(modifyResponse)
mrWithOpts.m = &Middleware{
impl: mrWithOpts,
modifyResponse: mrWithOpts.modifyResponse,
}
mrWithOpts.modifyResponseOpts = new(modifyResponseOpts)
err := U.Deserialize(optsRaw, mrWithOpts.modifyResponseOpts)
if err != nil {
return nil, E.FailWith("set options", err)
}
return mrWithOpts.m, nil
}
return
}
func (mr *modifyResponse) modifyResponse(resp *http.Response) error {
for k, v := range mr.SetHeaders {
resp.Header.Set(k, v)
}
for k, v := range mr.AddHeaders {
resp.Header.Add(k, v)
}
for _, k := range mr.HideHeaders {
resp.Header.Del(k)
}
return nil
}

View File

@@ -1,9 +0,0 @@
package middleware
var AddXForwarded = &Middleware{
rewrite: (*ProxyRequest).AddXForwarded,
}
var SetXForwarded = &Middleware{
rewrite: (*ProxyRequest).SetXForwarded,
}

View File

@@ -5,15 +5,15 @@ import (
"net/url"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
P "github.com/yusing/go-proxy/internal/proxy"
"github.com/yusing/go-proxy/internal/types"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type (
Route interface {
RouteImpl
Entry() *M.RawEntry
Entry() *types.RawEntry
Type() RouteType
URL() *url.URL
}
@@ -22,13 +22,14 @@ type (
RouteImpl interface {
Start() E.NestedError
Stop() E.NestedError
Started() bool
String() string
}
RouteType string
route struct {
RouteImpl
type_ RouteType
entry *M.RawEntry
entry *types.RawEntry
}
)
@@ -40,15 +41,15 @@ const (
// function alias
var NewRoutes = F.NewMapOf[string, Route]
func NewRoute(en *M.RawEntry) (Route, E.NestedError) {
rt, err := P.ValidateEntry(en)
func NewRoute(en *types.RawEntry) (Route, E.NestedError) {
entry, err := P.ValidateEntry(en)
if err != nil {
return nil, err
}
var t RouteType
switch e := rt.(type) {
var rt RouteImpl
switch e := entry.(type) {
case *P.StreamEntry:
rt, err = NewStreamRoute(e)
t = RouteTypeStream
@@ -61,10 +62,10 @@ func NewRoute(en *M.RawEntry) (Route, E.NestedError) {
if err != nil {
return nil, err
}
return &route{RouteImpl: rt.(RouteImpl), entry: en, type_: t}, nil
return &route{RouteImpl: rt, entry: en, type_: t}, nil
}
func (rt *route) Entry() *M.RawEntry {
func (rt *route) Entry() *types.RawEntry {
return rt.entry
}
@@ -73,15 +74,15 @@ func (rt *route) Type() RouteType {
}
func (rt *route) URL() *url.URL {
url, _ := url.Parse(fmt.Sprintf("%s://%s", rt.entry.Scheme, rt.entry.Host))
url, _ := url.Parse(fmt.Sprintf("%s://%s:%s", rt.entry.Scheme, rt.entry.Host, rt.entry.Port))
return url
}
func FromEntries(entries M.RawEntries) (Routes, E.NestedError) {
func FromEntries(entries types.RawEntries) (Routes, E.NestedError) {
b := E.NewBuilder("errors in routes")
routes := NewRoutes()
entries.RangeAll(func(alias string, entry *M.RawEntry) {
entries.RangeAll(func(alias string, entry *types.RawEntry) {
entry.Alias = alias
r, err := NewRoute(entry)
if err.HasError() {

Some files were not shown because too many files have changed in this diff Show More