Compare commits

..

31 Commits
0.5.4 ... 0.5.8

Author SHA1 Message Date
yusing
415f169f48 added explicit only mode for docker provider, updated dependencies 2024-09-29 11:24:41 +08:00
yusing
e2b08d8667 ci speedup 2024-09-29 06:00:52 +08:00
yusing
91e7f4894a fixed some containers being excluded on restart 2024-09-28 12:34:06 +08:00
yusing
a78dba5191 added response message on invalid api request host 2024-09-28 11:55:26 +08:00
yusing
c7208c90c6 updated example 2024-09-28 11:51:47 +08:00
yusing
da6a2756fa custom error page enabled for default for non-exist routes and invalid host 2024-09-28 11:45:01 +08:00
yusing
9a6a66f5a8 fixing dockerfile 2024-09-28 09:58:08 +08:00
yusing
90487bfde6 restructured the project to comply community guideline, for others check release note 2024-09-28 09:51:34 +08:00
yusing
4120fd8d1c fixed unchecked integer conversion, fixed 'invalid host' bug, corrected error message 2024-09-28 01:20:18 +08:00
yusing
6f3a5ebe6e Update documentation for Docker labels and middlewares, now fields works for snake cases, camel cases, pascal cases 2024-09-27 23:44:45 +08:00
yusing
a935f200a3 removed config example from README, check config.example.yml for complete explaination 2024-09-27 09:59:36 +08:00
yusing
f474ae4f75 added support for a few middlewares, added match_domain option, changed index reference prefix from $ to #, etc. 2024-09-27 09:57:57 +08:00
yusing
345a4417a6 changing alias index prefix from '$' to '#', to avoid unquoted/unescaped dollar sign being treated as interpolation 2024-09-27 00:34:14 +08:00
yusing
8cca83723c added discord invite badge 2024-09-27 00:23:46 +08:00
Yuzerion
aa2fcd47c2 Update docker.md 2024-09-26 22:52:56 +08:00
Yuzerion
0580a7d3cd Update config.example.yml 2024-09-26 22:51:29 +08:00
Yuzerion
a43c242c66 fixing wrong format for config example 2024-09-26 22:50:56 +08:00
yusing
45d4b92fc6 Fixed missing error subject 2024-09-26 20:11:05 +08:00
yusing
72df9ff3e4 Initial abstract implementation of middlewares 2024-09-25 14:12:40 +08:00
yusing
48bf31fd0e refactor file names, readme updates, removed frontend submodule as it is being built independently 2024-09-25 11:22:25 +08:00
yusing
4ee5383f7d github ci fix attempt, speedup docker build on CI 2024-09-25 10:46:45 +08:00
yusing
33fb60a32d Refactor Docker CI workflow for multi-platform builds 2024-09-25 10:09:50 +08:00
yusing
d10d0e49fa Update default wake timeout to 30 seconds, fixed port selection, improved idlewatcher 2024-09-25 05:27:12 +08:00
yusing
dc3575c8fd Refactoring 2024-09-25 03:43:47 +08:00
yusing
17115cfb0b Refactor and fixed port and scheme assignment logic in FillMissingFields.
Fixed issue that when a container is stopped or network=host, it will be excluded.
2024-09-25 03:40:57 +08:00
yusing
498082f7e5 no longer exclude a stopped container that have user specified port 2024-09-25 00:56:33 +08:00
yusing
99216ffe59 fixed subdomain matching for sub-sub-subdomain and so on, now return 404 when subdomain is missing 2024-09-24 18:30:25 +08:00
yusing
f426dbc9cf removed save registration.json since it does not work properly 2024-09-24 18:18:37 +08:00
yusing
1c611cc9b9 example fix 2024-09-24 03:13:39 +08:00
yusing
dc43e26770 Format README for better clarity in setup instructions 2024-09-24 03:09:50 +08:00
yusing
79ae26f1b5 new simpler setup method, readme and doc update 2024-09-23 22:10:13 +08:00
152 changed files with 3961 additions and 1605 deletions

View File

@@ -1,21 +1,132 @@
name: Docker Image CI
on:
push:
tags:
- "*"
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Set up Docker Build and Push
id: docker_build_push
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
tags: ${{ github.ref_name }}
push:
tags: ["*"]
- name: Tag as latest
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref_name, '-')
run: |
docker tag ghcr.io/${{ github.repository }}:${{ github.ref_name }} ghcr.io/${{ github.repository }}:latest
docker push ghcr.io/${{ github.repository }}:latest
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build multi-platform Docker image
runs-on: self-hosted
permissions:
contents: read
packages: write
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm/v6
- linux/arm/v7
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
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
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: self-hosted
needs:
- build
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
id: push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
- 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

9
.gitignore vendored
View File

@@ -3,8 +3,7 @@ compose.yml
config*/
certs*/
bin/
templates/codemirror/
error_pages/
logs/
log/
@@ -13,8 +12,10 @@ log/
go.work.sum
!src/**/
!cmd/**/
!internal/**/
todo.md
.*.swp
.*.swp
.aider*

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "frontend"]
path = frontend
url = https://github.com/yusing/go-proxy-frontend

View File

@@ -1,23 +1,43 @@
# Stage 1: Builder
FROM golang:1.23.1-alpine AS builder
RUN apk add --no-cache tzdata
COPY src /src
ENV GOCACHE=/root/.cache/go-build
WORKDIR /src
# Only copy go.mod and go.sum initially for better caching
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
ENV GOCACHE=/root/.cache/go-build
# Build the application with better caching
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
go mod download && \
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy github.com/yusing/go-proxy
--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
# Stage 2: Final image
FROM scratch
LABEL maintainer="yusing@6uo.me"
LABEL proxy.exclude=1
# copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# copy binary
COPY --from=builder /src/go-proxy /app/
COPY schema/ /app/schema
COPY --from=builder /app /app
# copy schema directory
COPY schema/ /app/schema/
# copy certs
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
ENV DOCKER_HOST=unix:///var/run/docker.sock
ENV GOPROXY_DEBUG=0
@@ -27,4 +47,5 @@ EXPOSE 8888
EXPOSE 443
WORKDIR /app
CMD ["/app/go-proxy"]
CMD ["/app/go-proxy"]

View File

@@ -1,6 +1,8 @@
.PHONY: all build up quick-restart restart logs get udp-server
BUILD_FLAG ?= -s -w
all: build quick-restart logs
.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
@@ -9,10 +11,11 @@ setup:
build:
mkdir -p bin
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy github.com/yusing/go-proxy
CGO_ENABLED=0 GOOS=linux \
go build -ldflags '${BUILD_FLAG}' -pgo=auto -o bin/go-proxy ./cmd
test:
go test ./src/...
go test ./internal/...
up:
docker compose up -d
@@ -24,10 +27,13 @@ logs:
docker compose logs -f
get:
cd src && go get -u && go mod tidy && cd ..
cd cmd && go get -u && go mod tidy && cd ..
debug:
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
make BUILD_FLAG="" build && sudo GOPROXY_DEBUG=1 bin/go-proxy
run:
make build && sudo bin/go-proxy
archive:
git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip
@@ -44,4 +50,8 @@ rapid-crash:
sudo docker rm -f test_crash
debug-list-containers:
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'
bash -c 'echo -e "GET /containers/json HTTP/1.0\r\n" | sudo netcat -U /var/run/docker.sock | tail -n +9 | jq'
ci-test:
mkdir -p /tmp/artifacts
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"

View File

@@ -5,6 +5,7 @@
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![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)
[繁體中文文檔請看此](README_CHT.md)
@@ -23,7 +24,7 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
- [Environment variables](#environment-variables)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Config File](#config-file)
- [Provider File](#provider-file)
- [Include Files](#include-files)
- [Showcase](#showcase)
- [idlesleeper](#idlesleeper)
- [Build it yourself](#build-it-yourself)
@@ -32,14 +33,18 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto certificate obtaining and renewal (See [Supported DNS Challenge Providers](docs/dns_providers.md))
- Auto SSL cert management (See [Supported DNS Challenge Providers](docs/dns_providers.md))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- 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 [showcase](#idlesleeper))_
- HTTP(s) reserve proxy
- [HTTP middleware support](docs/middlewares.md) _(experimental)_
- [Custom error pages support](docs/middlewares.md#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
- Written in **[Go](https://go.dev)**
[🔼Back to top](#table-of-content)
@@ -48,18 +53,29 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
### Setup
1. Setup DNS Records, e.g.
1. Pull docker image
```shell
docker pull ghcr.io/yusing/go-proxy:latest
```
- A Record: `*.y.z` -> `10.0.10.1`
- AAAA Record: `*.y.z` -> `::ffff:a00:a01`
2. Create new directory, `cd` into it, then run setup
2. Setup `go-proxy` [See here](docs/docker.md)
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
```
3. Setup `docker-socket-proxy` (see [example](docs/docker_socket_proxy.md) other machine that is running docker (if any)
3. Setup DNS Records point to machine which runs `go-proxy`, e.g.
4. Configure `go-proxy`
- with text editor (e.g. Visual Studio Code)
- or with web config editor via `http://gp.y.z`
- 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`
5. Done. 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)
[🔼Back to top](#table-of-content)
@@ -73,7 +89,7 @@ A lightweight, easy-to-use, and [performant](docs/benchmark_result.md) reverse p
| `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 <container_name> /app/go-proxy <command>`**
**run with `docker exec go-proxy /app/go-proxy <command>`**
### Environment variables
@@ -93,31 +109,13 @@ Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscod
### Config File
See [config.example.yml](config.example.yml) for more
```yaml
# autocert configuration
autocert:
email: # ACME Email
domains: # a list of domains for cert registration
provider: # DNS Challenge provider
options: # provider specific options
- ...
# reverse proxy providers configuration
providers:
include:
- providers.yml
- other_file_1.yml
- ...
docker:
local: $DOCKER_HOST
remote-1: tcp://10.0.2.1:2375
remote-2: ssh://root:1234@10.0.2.2
```
See [config.example.yml](config.example.yml)
[🔼Back to top](#table-of-content)
### Provider File
### Include Files
These are files that include standalone proxy entries
See [Fields](docs/docker.md#fields)

View File

@@ -5,6 +5,7 @@
[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![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)的反向代理和端口轉發工具
@@ -30,14 +31,16 @@
- 易用
- 不需花費太多時間就能輕鬆配置
- 支持多個docker節點
- 除錯簡單
- 自動處理 HTTPS 證書(參見[可用的 DNS 供應商](docs/dns_providers.md)
- 自動配置 SSL 證書(參見[可用的 DNS 供應商](docs/dns_providers.md)
- 透過 Docker 容器自動配置
- 容器狀態變更時自動熱重載
- 容器閒置時自動暫停/停止,入站時自動喚醒
- HTTP(s)反向代理
- HTTP(s) 反向代理
- 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 多平台
- 使用 **[Go](https://go.dev)** 編寫
[🔼 返回頂部](#目錄)
@@ -46,16 +49,29 @@
### 安裝
1. 設置 DNS 記錄,例如:
1. 抓取Docker鏡像
```shell
docker pull ghcr.io/yusing/go-proxy:latest
```
2. 建立新的目錄,並切換到該目錄,並執行
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
```
3. 設置 DNS 記錄,例如:
- A 記錄: `*.y.z` -> `10.0.10.1`
- AAAA 記錄: `*.y.z` -> `::ffff:a00:a01`
2. 安裝 `go-proxy` [參見這裡](docs/docker.md)
4. 配置 `docker-socket-proxy` 其他 Docker 節點(如有) (參見 [範例](docs/docker_socket_proxy.md)) 然後加到 `config.yml` 中
3. 配置 `go-proxy`
5. 大功告成,你可以做一些額外的配置
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
- 或通過 `http://gp.y.z` 使用網頁配置編輯器
- 詳情請參閱 [docker.md](docs/docker.md)
[🔼 返回頂部](#目錄)
@@ -69,7 +85,7 @@
| `ls-config` | 列出配置並退出 | `go-proxy ls-config \| jq` |
| `ls-route` | 列出路由並退出 | `go-proxy ls-route \| jq` |
**使用 `docker exec <容器名稱> /app/go-proxy <參數>` 運行**
**使用 `docker exec go-proxy /app/go-proxy <參數>` 運行**
### 環境變量
@@ -89,27 +105,7 @@
### 配置文件
參見 [config.example.yml](config.example.yml) 了解更多
```yaml
# autocert 配置
autocert:
email: # ACME 電子郵件
domains: # 域名列表
provider: # DNS 供應商
options: # 供應商個別配置
- ...
# 配置文件 / docker
providers:
include:
- providers.yml
- other_file_1.yml
- ...
docker:
local: $DOCKER_HOST
remote-1: tcp://10.0.2.1:2375
remote-2: ssh://root:1234@10.0.2.2
```
參見 [config.example.yml](config.example.yml)
[🔼 返回頂部](#目錄)

View File

@@ -16,20 +16,27 @@ import (
"time"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/api"
apiUtils "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/common"
"github.com/yusing/go-proxy/config"
"github.com/yusing/go-proxy/docker"
"github.com/yusing/go-proxy/docker/idlewatcher"
E "github.com/yusing/go-proxy/error"
R "github.com/yusing/go-proxy/route"
"github.com/yusing/go-proxy/server"
F "github.com/yusing/go-proxy/utils/functional"
"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/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"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/server"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
func main() {
args := common.GetArgs()
if args.Command == common.CommandSetup {
internal.Setup()
return
}
l := logrus.WithField("module", "main")
onShutdown := F.NewSlice[func()]()
@@ -41,11 +48,10 @@ func main() {
logrus.SetOutput(io.Discard)
} else {
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
DisableLevelTruncation: true,
FullTimestamp: true,
ForceColors: true,
TimestampFormat: "01-02 15:04:05",
DisableSorting: true,
FullTimestamp: true,
ForceColors: true,
TimestampFormat: "01-02 15:04:05",
})
}
@@ -70,11 +76,16 @@ func main() {
return
}
cfg, err := config.Load()
if err.IsFatal() {
log.Fatal(err)
for _, dir := range common.RequiredDirectories {
prepareDirectory(dir)
}
err := config.Load()
if err != nil {
logrus.Warn(err)
}
cfg := config.GetInstance()
switch args.Command {
case common.CommandListConfigs:
printJSON(cfg.Value())
@@ -90,6 +101,10 @@ func main() {
return
}
if common.IsDebug {
printJSON(docker.GetRegisteredNamespaces())
}
cfg.StartProxyProviders()
if err.HasError() {
@@ -110,10 +125,7 @@ func main() {
if autocert != nil {
ctx, cancel := context.WithCancel(context.Background())
if err = autocert.Setup(ctx); err != nil && err.IsWarning() {
cancel()
l.Warn(err)
} else if err.IsFatal() {
if err = autocert.Setup(ctx); err != nil {
l.Fatal(err)
} else {
onShutdown.Add(cancel)
@@ -180,13 +192,21 @@ func main() {
}
}
func prepareDirectory(dir string) {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err = os.MkdirAll(dir, 0755); err != nil {
logrus.Fatalf("failed to create directory %s: %v", dir, err)
}
}
}
func funcName(f func()) string {
parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), "/go-proxy/")
return parts[len(parts)-1]
}
func printJSON(obj any) {
j, err := E.Check(json.Marshal(obj))
j, err := E.Check(json.MarshalIndent(obj, "", " "))
if err.HasError() {
logrus.Fatal(err)
}

View File

@@ -1,37 +1,69 @@
# Autocert (choose one below and uncomment to enable)
#
# 1. use existing cert
#
# autocert:
# provider: local
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
# key_path: certs/priv.key # optional, uncomment only if you need to change it
#
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
# key_path: certs/priv.key # optional, uncomment only if you need to change it
#
# 2. cloudflare
#
# autocert:
# provider: cloudflare
# email: # ACME Email
# domains: # a list of domains for cert registration
# - x.y.z
# email: abc@gmail.com # ACME Email
# domains: # a list of domains for cert registration
# - "*.y.z" # remember to use double quotes to surround wildcard domain
# options:
# - auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
# 3. other providers, check readme for more
# auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
#
# 3. other providers, check docs/dns_providers.md for more
providers:
include:
- providers.yml # config/providers.yml
# add some more below if you want
# - file1.yml # config/file_1.yml
# - file2.yml
# include files are standalone yaml files under `config/` directory
#
# include:
# - file1.yml
# - file2.yml
docker:
# for value format, see https://docs.docker.com/reference/cli/dockerd/
# $DOCKER_HOST implies unix:///var/run/docker.sock by default
# $DOCKER_HOST implies environment variable `DOCKER_HOST` or unix:///var/run/docker.sock by default
local: $DOCKER_HOST
# explicit only mode
# only containers with explicit aliases will be proxied
# add "!" after provider name to enable explicit only mode
#
# local!: $DOCKER_HOST
#
# add more docker providers if needed
# for value format, see https://docs.docker.com/reference/cli/dockerd/
#
# remote-1: tcp://10.0.2.1:2375
# remote-2: ssh://root:1234@10.0.2.2
# if match_domains not defined
# any host = alias+[any domain] will match
# i.e. https://app1.y.z will match alias app1 for any domain y.z
# but https://app1.node1.y.z will only match alias "app.node1"
#
# if match_domains defined
# only host = alias+[one of match_domains] will match
# i.e. match_domains = [node1.my.app, my.site]
# https://app1.my.app, https://app1.my.net, etc. will not match even if app1 exists
# only https://*.node1.my.app and https://*.my.site will match
#
#
# match_domains:
# - my.site
# - node1.my.app
# Fixed options (optional, non hot-reloadable)
# Below are fixed options (non hot-reloadable)
# timeout for shutdown (in seconds)
#
# timeout_shutdown: 5
# global setting redirect http requests to https (if https available, otherwise this will be ignored)
# proxy.<alias>.middlewares.redirect_http will override this
#
# redirect_to_https: false

View File

@@ -11,42 +11,70 @@
## 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
- `client_id`
- `email`
- `password`
```yaml
autocert:
provider: clouddns
options:
client_id:
email:
password:
```
## DuckDNS
- `token`: DuckDNS Token
```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`
- `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

View File

@@ -6,7 +6,7 @@
- [Docker compose guide](#docker-compose-guide)
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Additional setup](#additional-setup)
- [Labels](#labels)
- [Syntax](#syntax)
- [Fields](#fields)
@@ -16,34 +16,11 @@
- [Docker compose examples](#docker-compose-examples)
- [Services URLs for above examples](#services-urls-for-above-examples)
## Setup
## Additional setup
1. Install `wget` if not already
1. Enable HTTPs _(optional)_
- Ubuntu based: `sudo apt install -y wget`
- Fedora based: `sudo yum install -y wget`
- Arch based: `sudo pacman -Sy wget`
2. Run setup script
`bash <(wget -qO- https://github.com/yusing/go-proxy/raw/main/setup-docker.sh)`
It will setup folder structure and required config files
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
Mount a folder (to store obtained certs) or (containing existing cert)
Mount a folder to store obtained certs or to load existing cert
```yaml
services:
@@ -59,57 +36,61 @@
autocert:
email: john.doe@x.y.z # ACME Email
domains: # a list of domains for cert registration
- x.y.z
- y.z
- *.y.z
provider: cloudflare
options:
- auth_token: c1234565789-abcdefghijklmnopqrst # your zone API token
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
```
5. Modify `compose.yml` to fit your needs
2. Modify `compose.yml` to fit your needs
6. Run `docker compose up -d` to start the container
3. Run `docker compose up -d` to start the container
7. Navigate to Web panel `http://gp.yourdomain.com` or use **Visual Studio Code (provides schema check)** to edit proxy config
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 | | `10s` | `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 |
| 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` **(required)** | proxy port **(tcp/udp)** | N/A | `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 | empty **(proxy all requests)** | yaml style list[<sup>1</sup>](#list-example) of ([path patterns](https://pkg.go.dev/net/http#hdr-Patterns-ServeMux)) |
| `set_headers` | header to set **(http/s only)** | empty | yaml style key-value mapping[<sup>2</sup>](#key-value-mapping-example) of header-value pairs |
| `hide_headers` | header to hide **(http/s only)** | empty | yaml style list[<sup>1</sup>](#list-example) of headers |
| 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)
@@ -122,12 +103,9 @@ services:
nginx:
...
labels:
# values from duplicated header keys will be combined
proxy.nginx.set_headers: | # remember to add the '|'
proxy.nginx.middlewares.modify_request.set_headers: | # remember to add the '|'
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
X-Custom-Header2: value4
# X-Custom-Header2 will be "value3, value4"
X-Custom-Header2: value3, value4
```
File Provider
@@ -135,10 +113,11 @@ File Provider
```yaml
service_a:
host: service_a.internal
set_headers:
# do not duplicate header keys, as it is not allowed in YAML
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
middlewares:
modify_request:
set_headers:
X-Custom-Header1: value1, value2
X-Custom-Header2: value3
```
[🔼Back to top](#table-of-content)
@@ -155,12 +134,12 @@ services:
proxy.nginx.path_patterns: | # remember to add the '|'
- GET /
- POST /auth
proxy.nginx.hide_headers: | # remember to add the '|'
proxy.nginx.middlewares.modify_request.hide_headers: | # remember to add the '|'
- X-Custom-Header1
- X-Custom-Header2
```
File Provider
Include file
```yaml
service_a:
@@ -168,9 +147,11 @@ service_a:
path_patterns:
- GET /
- POST /auth
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
middlewares:
modify_request:
hide_headers:
- X-Custom-Header1
- X-Custom-Header2
```
[🔼Back to top](#table-of-content)
@@ -230,10 +211,10 @@ services:
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
- 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
@@ -266,8 +247,8 @@ services:
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.$1.port=20002:8211
- proxy.$2.port=20003:27015
- proxy.#1.port=20002:8211
- proxy.#2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld

View File

@@ -23,7 +23,7 @@ docker-proxy:
ports:
- 2375:2375
# or more secure
- <machine_ip>:2375:2375
- <machine_private_ip>:2375:2375
```
```yml

333
docs/middlewares.md Normal file
View File

@@ -0,0 +1,333 @@
# 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)

Submodule frontend deleted from 441fd708db

View File

@@ -1,6 +1,6 @@
module github.com/yusing/go-proxy
go 1.22.0
go 1.23.1
require (
github.com/docker/cli v27.3.1+incompatible
@@ -17,7 +17,7 @@ require (
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudflare/cloudflare-go v0.104.0 // indirect
github.com/cloudflare/cloudflare-go v0.106.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
@@ -29,6 +29,7 @@ require (
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/miekg/dns v1.1.62 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
@@ -37,11 +38,12 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/ovh/go-ovh v1.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
go.opentelemetry.io/otel v1.30.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.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/mod v0.21.0 // indirect

View File

@@ -4,10 +4,11 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cloudflare/cloudflare-go v0.104.0 h1:R/lB0dZupaZbOgibAH/BRrkFbZ6Acn/WsKg2iX2xXuY=
github.com/cloudflare/cloudflare-go v0.104.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
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/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=
@@ -43,14 +44,16 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
@@ -69,14 +72,16 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/ovh/go-ovh v1.6.0 h1:ixLOwxQdzYDx296sXcgS35TOPEahJkpjMGtzPadCjQI=
github.com/ovh/go-ovh v1.6.0/go.mod h1:cTVDnl94z4tl8pP1uZ/8jlVxntjSIf09bNcQ5TJSC7c=
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/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.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis=
github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -91,18 +96,18 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
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=
@@ -148,14 +153,14 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
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/googleapis/api v0.0.0-20240311132316-a219d84964c2 h1:rIo7ocm2roD9DcFIX67Ym8icoGCKSARAiPljFhh5suQ=
google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2/go.mod h1:O1cOfN1Cy6QEYr7VxtjOyP5AdAuR0aJ/MYZaaof623Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/grpc v1.63.1 h1:pNClQmvdlyNUiwFETOux/PYqfhmA7BrswEdGRnib1fA=
google.golang.org/grpc v1.63.1/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -1,5 +0,0 @@
go 1.22.0
toolchain go1.23.1
use ./src

58
internal/api/handler.go Normal file
View File

@@ -0,0 +1,58 @@
package api
import (
"fmt"
"net/http"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/error_page"
. "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
)
type ServeMux struct{ *http.ServeMux }
func NewServeMux() ServeMux {
return ServeMux{http.NewServeMux()}
}
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
mux.ServeMux.HandleFunc(fmt.Sprintf("%s %s", method, endpoint), checkHost(handler))
}
func NewHandler(cfg *config.Config) http.Handler {
mux := NewServeMux()
mux.HandleFunc("GET", "/v1", v1.Index)
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/stats", wrap(cfg, v1.Stats))
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 {
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.Write([]byte("invalid request"))
return
}
f(w, r)
}
}
func wrap(cfg *config.Config, f func(cfg *config.Config, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f(cfg, w, r)
}
}

View File

@@ -5,9 +5,9 @@ import (
"net/http"
"strings"
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/config"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/config"
R "github.com/yusing/go-proxy/internal/route"
)
func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,88 @@
package error_page
import (
"context"
"fmt"
"os"
"path"
"sync"
api "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
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"
"github.com/yusing/go-proxy/internal/watcher/events"
)
const errPagesBasePath = common.ErrorPagesBasePath
var setup = sync.OnceFunc(func() {
dirWatcher = W.NewDirectoryWatcher(context.Background(), errPagesBasePath)
loadContent()
go watchDir()
})
func GetStaticFile(filename string) ([]byte, bool) {
return fileContentMap.Load(filename)
}
// try <statusCode>.html -> 404.html -> not ok
func GetErrorPageByStatus(statusCode int) (content []byte, ok bool) {
content, ok = fileContentMap.Load(fmt.Sprintf("%d.html", statusCode))
if !ok && statusCode != 404 {
return fileContentMap.Load("404.html")
}
return
}
func loadContent() {
files, err := U.ListFiles(errPagesBasePath, 0)
if err != nil {
api.Logger.Error(err)
return
}
for _, file := range files {
if fileContentMap.Has(file) {
continue
}
content, err := os.ReadFile(file)
if err != nil {
api.Logger.Errorf("failed to read error page resource %s: %s", file, err)
continue
}
file = path.Base(file)
api.Logger.Infof("error page resource %s loaded", file)
fileContentMap.Store(file, content)
}
}
func watchDir() {
eventCh, errCh := dirWatcher.Events(context.Background())
for {
select {
case event, ok := <-eventCh:
if !ok {
return
}
filename := event.ActorName
switch event.Action {
case events.ActionFileWritten:
fileContentMap.Delete(filename)
loadContent()
case events.ActionFileDeleted:
fileContentMap.Delete(filename)
api.Logger.Infof("error page resource %s deleted", filename)
case events.ActionFileRenamed:
api.Logger.Infof("error page resource %s deleted", filename)
fileContentMap.Delete(filename)
loadContent()
}
case err := <-errCh:
api.Logger.Errorf("error watching error page directory: %s", err)
}
}
}
var dirWatcher W.Watcher
var fileContentMap = F.NewMapOf[string, []byte]()

View File

@@ -0,0 +1,25 @@
package error_page
import "net/http"
func GetHandleFunc() http.HandlerFunc {
setup()
return serveHTTP
}
func serveHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if r.URL.Path == "/" {
http.Error(w, "invalid path", http.StatusNotFound)
return
}
content, ok := fileContentMap.Load(r.URL.Path)
if !ok {
http.Error(w, "404 not found", http.StatusNotFound)
return
}
w.Write(content)
}

View File

@@ -6,10 +6,11 @@ import (
"os"
"path"
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/common"
"github.com/yusing/go-proxy/config"
"github.com/yusing/go-proxy/proxy/provider"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/proxy/provider"
)
func GetFileContent(w http.ResponseWriter, r *http.Request) {
@@ -37,14 +38,15 @@ func SetFileContent(w http.ResponseWriter, r *http.Request) {
return
}
var validateErr E.NestedError
if filename == common.ConfigFileName {
err = config.Validate(content).Error()
validateErr = config.Validate(content)
} else {
err = provider.Validate(content).Error()
validateErr = provider.Validate(content)
}
if err != nil {
U.HandleErr(w, r, err, http.StatusBadRequest)
if validateErr != nil {
U.RespondJson(w, validateErr.JSONObject(), http.StatusBadRequest)
return
}

View File

@@ -5,10 +5,9 @@ import (
"net/http"
"os"
"github.com/yusing/go-proxy/common"
"github.com/yusing/go-proxy/config"
U "github.com/yusing/go-proxy/api/v1/utils"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
)
func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
@@ -38,7 +37,7 @@ func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
}
}
if err := U.RespondJson(routes, w); err != nil {
if err := U.RespondJson(w, routes); err != nil {
U.HandleErr(w, r, err)
}
}

16
internal/api/v1/reload.go Normal file
View File

@@ -0,0 +1,16 @@
package v1
import (
"net/http"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/config"
)
func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
if err := cfg.Reload(); err != nil {
U.RespondJson(w, err.JSONObject(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}

View File

@@ -3,10 +3,10 @@ package v1
import (
"net/http"
U "github.com/yusing/go-proxy/api/v1/utils"
"github.com/yusing/go-proxy/config"
"github.com/yusing/go-proxy/server"
"github.com/yusing/go-proxy/utils"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/server"
"github.com/yusing/go-proxy/internal/utils"
)
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
@@ -14,7 +14,7 @@ func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
"proxies": cfg.Statistics(),
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
}
if err := U.RespondJson(stats, w); err != nil {
if err := U.RespondJson(w, stats); err != nil {
U.HandleErr(w, r, err)
}
}

View File

@@ -6,12 +6,14 @@ import (
"net/http"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
var Logger = logrus.WithField("module", "api")
func HandleErr(w http.ResponseWriter, r *http.Request, origErr error, code ...int) {
err := E.From(origErr).Subjectf("%s %s", r.Method, r.URL)
logrus.WithField("module", "api").Error(err)
Logger.Error(err)
if len(code) > 0 {
http.Error(w, err.String(), code[0])
return

View File

@@ -0,0 +1,33 @@
package utils
import (
"net"
"net/http"
"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)
if resp != nil {
resp.Body.Close()
}
if err != nil && resp != nil && resp.StatusCode == http.StatusMethodNotAllowed {
_, err = httpClient.Get(url)
}
if resp != nil {
resp.Body.Close()
}
return err == nil
}
func IsStreamHealthy(scheme, address string) bool {
conn, err := net.DialTimeout(scheme, address, common.DialTimeout)
if err != nil {
return false
}
conn.Close()
return true
}

View File

@@ -0,0 +1,23 @@
package utils
import (
"crypto/tls"
"net"
"net/http"
"github.com/yusing/go-proxy/internal/common"
)
var httpClient = &http.Client{
Timeout: common.ConnectionTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DisableKeepAlives: true,
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: common.DialTimeout,
KeepAlive: common.KeepAlive, // this is different from DisableKeepAlives
}).DialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}

View File

@@ -0,0 +1,31 @@
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

@@ -5,9 +5,12 @@ import (
"net/http"
)
func RespondJson(data any, w http.ResponseWriter) error {
func RespondJson(w http.ResponseWriter, data any, code ...int) error {
if len(code) > 0 {
w.WriteHeader(code[0])
}
w.Header().Set("Content-Type", "application/json")
j, err := json.Marshal(data)
j, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
} else {

View File

@@ -7,8 +7,8 @@ import (
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
)
type Config M.AutoCertConfig
@@ -32,13 +32,13 @@ func (cfg *Config) GetProvider() (provider *Provider, res E.NestedError) {
if cfg.Provider != ProviderLocal {
if len(cfg.Domains) == 0 {
b.Addf("no domains specified")
b.Addf("%s", "no domains specified")
}
if cfg.Provider == "" {
b.Addf("no provider specified")
b.Addf("%s", "no provider specified")
}
if cfg.Email == "" {
b.Addf("no email specified")
b.Addf("%s", "no email specified")
}
// check if provider is implemented
_, ok := providersGenMap[cfg.Provider]

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"os"
"path"
"reflect"
"sort"
"time"
@@ -13,9 +14,9 @@ import (
"github.com/go-acme/lego/v4/challenge"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
U "github.com/yusing/go-proxy/utils"
E "github.com/yusing/go-proxy/internal/error"
M "github.com/yusing/go-proxy/internal/models"
U "github.com/yusing/go-proxy/internal/utils"
)
type Provider struct {
@@ -59,8 +60,7 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
defer b.To(&res)
if p.cfg.Provider == ProviderLocal {
b.Addf("provider is set to %q", ProviderLocal).WithSeverity(E.SeverityWarning)
return
return nil
}
if p.client == nil {
@@ -71,11 +71,9 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
}
if p.user.Registration == nil {
if err := p.loadRegistration(); err.HasError() {
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
}
@@ -89,16 +87,18 @@ func (p *Provider) ObtainCert() (res E.NestedError) {
b.Add(err)
return
}
err = p.saveCert(cert)
if err.HasError() {
if err = p.saveCert(cert); err.HasError() {
b.Add(E.FailWith("save certificate", err))
return
}
tlsCert, err := E.Check(tls.X509KeyPair(cert.Certificate, cert.PrivateKey))
if err.HasError() {
b.Add(E.FailWith("parse obtained certificate", err))
return
}
expiries, err := getCertExpiries(&tlsCert)
if err.HasError() {
b.Add(E.FailWith("get certificate expiry", err))
@@ -187,31 +187,23 @@ func (p *Provider) registerACME() E.NestedError {
}
p.user.Registration = reg
if err := p.saveRegistration(); err.HasError() {
logger.Warn(err)
}
return nil
}
func (p *Provider) loadRegistration() E.NestedError {
if p.user.Registration != nil {
return nil
}
reg := &registration.Resource{}
err := U.LoadJson(RegistrationFile, reg)
if err.HasError() {
return E.FailWith("parse registration file", err)
}
p.user.Registration = reg
return nil
}
func (p *Provider) saveRegistration() E.NestedError {
return U.SaveJson(RegistrationFile, p.user.Registration, 0o600)
}
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
err := os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
//* This should have been done in setup
//* but double check is always a good choice
_, err := os.Stat(path.Dir(p.cfg.CertPath))
if err != nil {
if os.IsNotExist(err) {
if err = os.MkdirAll(path.Dir(p.cfg.CertPath), 0o755); err != nil {
return E.FailWith("create cert directory", err)
}
} else {
return E.FailWith("stat cert directory", err)
}
}
err = os.WriteFile(p.cfg.KeyPath, cert.PrivateKey, 0o600) // -rw-------
if err != nil {
return E.FailWith("write key file", err)
}
@@ -247,6 +239,10 @@ func (p *Provider) certState() CertState {
}
func (p *Provider) renewIfNeeded() E.NestedError {
if p.cfg.Provider == ProviderLocal {
return nil
}
switch p.certState() {
case CertStateExpired:
logger.Info("certs expired, renewing")

View File

@@ -4,8 +4,8 @@ import (
"testing"
"github.com/go-acme/lego/v4/providers/dns/ovh"
U "github.com/yusing/go-proxy/utils"
. "github.com/yusing/go-proxy/utils/testing"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
"gopkg.in/yaml.v3"
)

View File

@@ -4,7 +4,7 @@ import (
"context"
"os"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
@@ -14,7 +14,7 @@ func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
}
logger.Debug("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err.Warn()
return err
}
}

View File

@@ -0,0 +1,9 @@
package autocert
type CertState int
const (
CertStateValid CertState = iota
CertStateExpired
CertStateMismatch
)

View File

@@ -4,7 +4,7 @@ import (
"flag"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type Args struct {
@@ -13,6 +13,7 @@ type Args struct {
const (
CommandStart = ""
CommandSetup = "setup"
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
@@ -23,6 +24,7 @@ const (
var ValidCommands = []string{
CommandStart,
CommandSetup,
CommandValidate,
CommandListConfigs,
CommandListRoutes,

View File

@@ -0,0 +1,52 @@
package common
import (
"time"
)
const (
ConnectionTimeout = 5 * time.Second
DialTimeout = 3 * time.Second
KeepAlive = 60 * time.Second
)
// file, folder structure
const (
ConfigBasePath = "config"
ConfigFileName = "config.yml"
ConfigExampleFileName = "config.example.yml"
ConfigPath = ConfigBasePath + "/" + ConfigFileName
)
const (
SchemaBasePath = "schema"
ConfigSchemaPath = SchemaBasePath + "/config.schema.json"
FileProviderSchemaPath = SchemaBasePath + "/providers.schema.json"
)
const (
ComposeFileName = "compose.yml"
ComposeExampleFileName = "compose.example.yml"
)
const (
ErrorPagesBasePath = "error_pages"
)
var (
RequiredDirectories = []string{
ConfigBasePath,
SchemaBasePath,
ErrorPagesBasePath,
}
)
const DockerHostFromEnv = "$DOCKER_HOST"
const (
IdleTimeoutDefault = "0"
WakeTimeoutDefault = "30s"
StopTimeoutDefault = "10s"
StopMethodDefault = "stop"
)

59
internal/common/env.go Normal file
View File

@@ -0,0 +1,59 @@
package common
import (
"fmt"
"net"
"os"
"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)
ProxyHTTPAddr,
ProxyHTTPHost,
ProxyHTTPPort,
ProxyHTTPURL = GetAddrEnv("GOPROXY_HTTP_ADDR", ":80", "http")
ProxyHTTPSAddr,
ProxyHTTPSHost,
ProxyHTTPSPort,
ProxyHTTPSURL = GetAddrEnv("GOPROXY_HTTPS_ADDR", ":443", "https")
APIHTTPAddr,
APIHTTPHost,
APIHTTPPort,
APIHTTPURL = GetAddrEnv("GOPROXY_API_ADDR", "127.0.0.1:8888", "http")
)
func GetEnvBool(key string, defaultValue bool) bool {
value, ok := os.LookupEnv(key)
if !ok || value == "" {
return defaultValue
}
return U.ParseBool(value)
}
func GetEnv(key, defaultValue string) string {
value, ok := os.LookupEnv(key)
if !ok || value == "" {
value = defaultValue
}
return value
}
func GetAddrEnv(key, defaultValue, scheme string) (addr, host, port, fullURL string) {
addr = GetEnv(key, defaultValue)
host, port, err := net.SplitHostPort(addr)
if err != nil {
logrus.Fatalf("Invalid address: %s", addr)
}
if host == "" {
host = "localhost"
}
fullURL = fmt.Sprintf("%s://%s:%s", scheme, host, port)
return
}

27
internal/common/http.go Normal file
View File

@@ -0,0 +1,27 @@
package common
import (
"crypto/tls"
"net"
"net/http"
"time"
)
var (
defaultDialer = net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}
DefaultTransport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
MaxIdleConnsPerHost: 1000,
}
DefaultTransportNoTLS = func() *http.Transport {
var clone = DefaultTransport.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
)
const StaticFilePathPrefix = "/$gperrorpage/"

View File

@@ -10,14 +10,15 @@ var (
}
ServiceNamePortMapTCP = map[string]int{
"mssql": 1433,
"mysql": 3306,
"mariadb": 3306,
"postgres": 5432,
"rabbitmq": 5672,
"redis": 6379,
"memcached": 11211,
"mongo": 27017,
"mssql": 1433,
"mysql": 3306,
"mariadb": 3306,
"postgres": 5432,
"rabbitmq": 5672,
"redis": 6379,
"memcached": 11211,
"mongo": 27017,
"minecraft-server": 25565,
"ssh": 22,
"ftp": 21,
@@ -53,13 +54,13 @@ var (
"immich": 3001,
"jellyfin": 8096,
"lidarr": 8686,
"minecraft-server": 25565,
"microbin": 8080,
"nginx": 80,
"nginx-proxy-manager": 81,
"open-webui": 8080,
"plex": 32400,
"portainer": 9000,
"portainer-ce": 9000,
"portainer-be": 9443,
"portainer-ce": 9443,
"prometheus": 9090,
"prowlarr": 9696,
"radarr": 7878,

View File

@@ -5,16 +5,16 @@ import (
"os"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/autocert"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
PR "github.com/yusing/go-proxy/proxy/provider"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
W "github.com/yusing/go-proxy/watcher"
"github.com/yusing/go-proxy/watcher/events"
"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"
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"
"github.com/yusing/go-proxy/internal/watcher/events"
"gopkg.in/yaml.v3"
)
@@ -31,25 +31,48 @@ type Config struct {
reloadReq chan struct{}
}
func Load() (*Config, E.NestedError) {
cfg := &Config{
var instance *Config
func GetInstance() *Config {
return instance
}
func Load() E.NestedError {
if instance != nil {
return nil
}
instance = &Config{
value: M.DefaultConfig(),
proxyProviders: F.NewMapOf[string, *PR.Provider](),
l: logrus.WithField("module", "config"),
watcher: W.NewFileWatcher(common.ConfigFileName),
watcher: W.NewConfigFileWatcher(common.ConfigFileName),
reloadReq: make(chan struct{}, 1),
}
return cfg, cfg.load()
return instance.load()
}
func Validate(data []byte) E.NestedError {
return U.ValidateYaml(U.GetSchema(common.ConfigSchemaPath), data)
}
func MatchDomains() []string {
if instance == nil {
logrus.Panic("config has not been loaded, please check if there is any errors")
}
return instance.value.MatchDomains
}
func (cfg *Config) Value() M.Config {
if cfg == nil {
logrus.Panic("config has not been loaded, please check if there is any errors")
}
return *cfg.value
}
func (cfg *Config) GetAutoCertProvider() *autocert.Provider {
if instance == nil {
logrus.Panic("config has not been loaded, please check if there is any errors")
}
return cfg.autocertProvider
}
@@ -61,13 +84,11 @@ func (cfg *Config) Dispose() {
cfg.stopProviders()
}
func (cfg *Config) Reload() E.NestedError {
func (cfg *Config) Reload() (err E.NestedError) {
cfg.stopProviders()
if err := cfg.load(); err.HasError() {
return err
}
err = cfg.load()
cfg.StartProxyProviders()
return nil
return
}
func (cfg *Config) StartProxyProviders() {
@@ -95,8 +116,9 @@ func (cfg *Config) WatchChanges() {
case <-cfg.watcherCtx.Done():
return
case event := <-eventCh:
if event.Action == events.ActionFileDeleted {
cfg.stopProviders()
if event.Action == events.ActionFileDeleted || event.Action == events.ActionFileRenamed {
cfg.l.Error("config file deleted or renamed, ignoring...")
continue
} else {
cfg.reloadReq <- struct{}{}
}
@@ -126,28 +148,28 @@ func (cfg *Config) load() (res E.NestedError) {
data, err := E.Check(os.ReadFile(common.ConfigPath))
if err.HasError() {
b.Add(E.FailWith("read config", err))
return
logrus.Fatal(b.Build())
}
if !common.NoSchemaValidation {
if err = Validate(data); err.HasError() {
b.Add(E.FailWith("schema validation", err))
return
logrus.Fatal(b.Build())
}
}
model := M.DefaultConfig()
if err := E.From(yaml.Unmarshal(data, model)); err.HasError() {
b.Add(E.FailWith("parse config", err))
return
logrus.Fatal(b.Build())
}
// errors are non fatal below
b.WithSeverity(E.SeverityWarning)
b.Add(cfg.initAutoCert(&model.AutoCert))
b.Add(cfg.loadProviders(&model.Providers))
cfg.value = model
R.SetFindMuxDomains(model.MatchDomains)
return
}

View File

@@ -1,11 +1,11 @@
package config
import (
M "github.com/yusing/go-proxy/models"
PR "github.com/yusing/go-proxy/proxy/provider"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
F "github.com/yusing/go-proxy/utils/functional"
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"
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 {

View File

@@ -8,9 +8,9 @@ import (
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
F "github.com/yusing/go-proxy/utils/functional"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type Client struct {

View File

@@ -8,7 +8,7 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type ClientInfo struct {

View File

@@ -6,36 +6,17 @@ import (
"strings"
"github.com/docker/docker/api/types"
U "github.com/yusing/go-proxy/utils"
U "github.com/yusing/go-proxy/internal/utils"
)
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
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
NetworkMode string `yaml:"-" json:"network_mode"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}
type Container struct {
*types.Container
*ProxyProperties
}
type PortMapping = map[string]types.Port
func FromDocker(c *types.Container, dockerHost string) (res Container) {
res.Container = c
isExplicit := c.Labels[LabelAliases] != ""
res.ProxyProperties = &ProxyProperties{
DockerHost: dockerHost,
ContainerName: res.getName(),
@@ -45,6 +26,7 @@ func FromDocker(c *types.Container, dockerHost string) (res Container) {
NetworkMode: c.HostConfig.NetworkMode,
Aliases: res.getAliases(),
IsExcluded: U.ParseBool(res.getDeleteLabel(LabelExclude)),
IsExplicit: isExplicit,
IdleTimeout: res.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: res.getDeleteLabel(LabelWakeTimeout),
StopMethod: res.getDeleteLabel(LabelStopMethod),
@@ -59,8 +41,8 @@ func FromJson(json types.ContainerJSON, dockerHost string) Container {
ports := make([]types.Port, 0)
for k, bindings := range json.NetworkSettings.Ports {
for _, v := range bindings {
pubPort, _ := strconv.Atoi(v.HostPort)
privPort, _ := strconv.Atoi(k.Port())
pubPort, _ := strconv.ParseUint(v.HostPort, 10, 16)
privPort, _ := strconv.ParseUint(k.Port(), 10, 16)
ports = append(ports, types.Port{
IP: v.HostIP,
PublicPort: uint16(pubPort),
@@ -68,7 +50,7 @@ func FromJson(json types.ContainerJSON, dockerHost string) Container {
})
}
}
return FromDocker(&types.Container{
cont := FromDocker(&types.Container{
ID: json.ID,
Names: []string{json.Name},
Image: json.Image,
@@ -77,6 +59,8 @@ func FromJson(json types.ContainerJSON, dockerHost string) Container {
State: json.State.Status,
Status: json.State.Status,
}, dockerHost)
cont.NetworkMode = string(json.HostConfig.NetworkMode)
return cont
}
func (c Container) getDeleteLabel(label string) string {
@@ -119,9 +103,6 @@ func (c Container) getPublicPortMapping() PortMapping {
func (c Container) getPrivatePortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
if v.PublicPort == 0 {
continue
}
res[fmt.Sprint(v.PrivatePort)] = v
}
return res

View File

@@ -19,13 +19,7 @@ type templateData struct {
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = func() *template.Template {
tmpl, err := template.New("loading").Parse(string(loadingPage))
if err != nil {
panic(err)
}
return tmpl
}()
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
const (
htmlContentType = "text/html; charset=utf-8"

View File

@@ -3,7 +3,6 @@ package idlewatcher
import (
"context"
"net/http"
"time"
)
type (
@@ -18,14 +17,17 @@ func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
}
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)
}
// wake the container
w.wakeCh <- struct{}{}
// initial request
targetUrl := req.Header.Get(headerGoProxyTargetURL)
if targetUrl == "" {
@@ -57,7 +59,6 @@ func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*ht
rtDone <- resp
return
}
time.Sleep(time.Millisecond * 200)
}
}
}()
@@ -66,6 +67,10 @@ func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*ht
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)

View File

@@ -9,11 +9,11 @@ import (
"github.com/docker/docker/api/types/container"
"github.com/sirupsen/logrus"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
P "github.com/yusing/go-proxy/proxy"
PT "github.com/yusing/go-proxy/proxy/fields"
W "github.com/yusing/go-proxy/watcher"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
P "github.com/yusing/go-proxy/internal/proxy"
PT "github.com/yusing/go-proxy/internal/proxy/fields"
W "github.com/yusing/go-proxy/internal/watcher"
)
type (
@@ -78,8 +78,8 @@ func Register(entry *P.ReverseProxyEntry) (*watcher, E.NestedError) {
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}, 1),
wakeDone: make(chan E.NestedError, 1),
wakeCh: make(chan struct{}),
wakeDone: make(chan E.NestedError),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)

View File

@@ -4,7 +4,7 @@ import (
"context"
"time"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
func (c Client) Inspect(containerID string) (Container, E.NestedError) {

151
internal/docker/label.go Normal file
View File

@@ -0,0 +1,151 @@
package docker
import (
"reflect"
"strings"
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"
)
/*
Formats:
- namespace.attribute
- namespace.target.attribute
- namespace.target.attribute.namespace2.attribute
*/
type (
Label struct {
Namespace string
Target string
Attribute string
Value any
}
NestedLabelMap map[string]U.SerializedObject
ValueParser func(string) (any, E.NestedError)
ValueParserMap map[string]ValueParser
)
func (l *Label) String() string {
if l.Attribute == "" {
return l.Namespace + "." + l.Target
}
return l.Namespace + "." + l.Target + "." + l.Attribute
}
// Apply applies the value of a Label to the corresponding field in the given object.
//
// Parameters:
// - obj: a pointer to the object to which the Label value will be applied.
// - l: a pointer to the Label containing the attribute and value to be applied.
//
// Returns:
// - error: an error if the field does not exist.
func ApplyLabel[T any](obj *T, l *Label) E.NestedError {
if obj == nil {
return E.Invalid("nil object", l)
}
switch nestedLabel := l.Value.(type) {
case *Label:
var field reflect.Value
objType := reflect.TypeFor[T]()
for i := 0; i < reflect.TypeFor[T]().NumField(); i++ {
if objType.Field(i).Tag.Get("yaml") == l.Attribute {
field = reflect.ValueOf(obj).Elem().Field(i)
break
}
}
if !field.IsValid() {
return E.NotExist("field", l.Attribute)
}
dst, ok := field.Interface().(NestedLabelMap)
if !ok {
return E.Invalid("type", field.Type())
}
if dst == nil {
field.Set(reflect.MakeMap(reflect.TypeFor[NestedLabelMap]()))
dst = field.Interface().(NestedLabelMap)
}
if dst[nestedLabel.Namespace] == nil {
dst[nestedLabel.Namespace] = make(U.SerializedObject)
}
dst[nestedLabel.Namespace][nestedLabel.Attribute] = nestedLabel.Value
return nil
default:
return U.Deserialize(U.SerializedObject{l.Attribute: l.Value}, obj)
}
}
func ParseLabel(label string, value string) (*Label, E.NestedError) {
parts := strings.Split(label, ".")
if len(parts) < 2 {
return &Label{
Namespace: label,
Value: value,
}, nil
}
l := &Label{
Namespace: parts[0],
Target: parts[1],
Value: value,
}
switch len(parts) {
case 2:
l.Attribute = l.Target
case 3:
l.Attribute = parts[2]
default:
l.Attribute = parts[2]
nestedLabel, err := ParseLabel(strings.Join(parts[3:], "."), value)
if err.HasError() {
return nil, err
}
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

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,106 @@
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

@@ -0,0 +1,85 @@
package docker
import (
"fmt"
"testing"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
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)
ExpectNoError(t, err.Error())
sGot := ExpectType[*Label](t, pl.Value)
ExpectFalse(t, sGot == nil)
ExpectEqual(t, sGot.Namespace, mName)
ExpectEqual(t, sGot.Attribute, mAttr)
}
func TestApplyNestedLabel(t *testing.T) {
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
mName := "middleware1"
mAttr := "prop1"
v := "value1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s.%s", ProxyAttributeMiddlewares, mName, mAttr)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
}
func TestApplyNestedLabelExisting(t *testing.T) {
mName := "middleware1"
mAttr := "prop1"
v := "value1"
checkAttr := "prop2"
checkV := "value2"
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
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)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
middleware1, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
got := ExpectType[string](t, middleware1[mAttr])
ExpectEqual(t, got, v)
// check if prop2 is affected
ExpectFalse(t, middleware1[checkAttr] == nil)
got = ExpectType[string](t, middleware1[checkAttr])
ExpectEqual(t, got, checkV)
}
func TestApplyNestedLabelNoAttr(t *testing.T) {
mName := "middleware1"
v := "value1"
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
entry.Middlewares = make(NestedLabelMap)
entry.Middlewares[mName] = make(U.SerializedObject)
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", ProxyAttributeMiddlewares, mName)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
_, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
}

View File

@@ -0,0 +1,23 @@
package docker
import "github.com/docker/docker/api/types"
type PortMapping = map[string]types.Port
type ProxyProperties struct {
DockerHost string `yaml:"-" json:"docker_host"`
ContainerName string `yaml:"-" json:"container_name"`
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
NetworkMode string `yaml:"-" json:"network_mode"`
Aliases []string `yaml:"-" json:"aliases"`
IsExcluded bool `yaml:"-" json:"is_excluded"`
IsExplicit bool `yaml:"-" json:"is_explicit"`
IdleTimeout string `yaml:"-" json:"idle_timeout"`
WakeTimeout string `yaml:"-" json:"wake_timeout"`
StopMethod string `yaml:"-" json:"stop_method"`
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
Running bool `yaml:"-" json:"running"`
}

View File

@@ -10,9 +10,8 @@ type Builder struct {
}
type builder struct {
message string
errors []NestedError
severity Severity
message string
errors []NestedError
sync.Mutex
}
@@ -40,11 +39,6 @@ func (b Builder) Addf(format string, args ...any) Builder {
return b.Add(errorf(format, args...))
}
func (b Builder) WithSeverity(s Severity) Builder {
b.severity = s
return b
}
// Build builds a NestedError based on the errors collected in the Builder.
//
// If there are no errors in the Builder, it returns a Nil() NestedError.
@@ -58,7 +52,7 @@ func (b Builder) Build() NestedError {
} else if len(b.errors) == 1 {
return b.errors[0]
}
return Join(b.message, b.errors...).Severity(b.severity)
return Join(b.message, b.errors...)
}
func (b Builder) To(ptr *NestedError) {

View File

@@ -3,7 +3,7 @@ package error
import (
"testing"
. "github.com/yusing/go-proxy/utils/testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestBuilderEmpty(t *testing.T) {

View File

@@ -1,6 +1,7 @@
package error
import (
"encoding/json"
"errors"
"fmt"
"strings"
@@ -9,17 +10,15 @@ import (
type (
NestedError = *nestedError
nestedError struct {
subject string
err error
extras []nestedError
severity Severity
subject string
err error
extras []nestedError
}
jsonNestedError struct {
Subject string
Err string
Extras []jsonNestedError
}
Severity uint8
)
const (
SeverityWarning Severity = iota
SeverityFatal
)
func From(err error) NestedError {
@@ -29,6 +28,29 @@ func From(err error) NestedError {
return &nestedError{err: err}
}
func FromJSON(data []byte) (NestedError, bool) {
var j jsonNestedError
if err := json.Unmarshal(data, &j); err != nil {
return nil, false
}
if j.Err == "" {
return nil, false
}
extras := make([]nestedError, len(j.Extras))
for i, e := range j.Extras {
extra, ok := fromJSONObject(e)
if !ok {
return nil, false
}
extras[i] = *extra
}
return &nestedError{
subject: j.Subject,
err: errors.New(j.Err),
extras: extras,
}, true
}
// Check is a helper function that
// convert (T, error) to (T, NestedError).
func Check[T any](obj T, err error) (T, NestedError) {
@@ -118,9 +140,9 @@ func (ne NestedError) With(s any) NestedError {
case string:
msg = ss
case fmt.Stringer:
return ne.append(ss.String())
return ne.appendMsg(ss.String())
default:
return ne.append(fmt.Sprint(s))
return ne.appendMsg(fmt.Sprint(s))
}
return ne.withError(From(errors.New(msg)))
}
@@ -133,13 +155,19 @@ func (ne NestedError) Subject(s any) NestedError {
if ne == nil {
return ne
}
var subject string
switch ss := s.(type) {
case string:
ne.subject = ss
subject = ss
case fmt.Stringer:
ne.subject = ss.String()
subject = ss.String()
default:
ne.subject = fmt.Sprint(s)
subject = fmt.Sprint(s)
}
if ne.subject == "" {
ne.subject = subject
} else {
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
}
return ne
}
@@ -158,20 +186,21 @@ func (ne NestedError) Subjectf(format string, args ...any) NestedError {
return ne
}
func (ne NestedError) Severity(s Severity) NestedError {
if ne == nil {
return ne
func (ne NestedError) JSONObject() jsonNestedError {
extras := make([]jsonNestedError, len(ne.extras))
for i, e := range ne.extras {
extras[i] = e.JSONObject()
}
return jsonNestedError{
Subject: ne.subject,
Err: ne.err.Error(),
Extras: extras,
}
ne.severity = s
return ne
}
func (ne NestedError) Warn() NestedError {
if ne == nil {
return ne
}
ne.severity = SeverityWarning
return ne
func (ne NestedError) JSON() []byte {
b, _ := json.MarshalIndent(ne.JSONObject(), "", " ")
return b
}
func (ne NestedError) NoError() bool {
@@ -182,18 +211,18 @@ func (ne NestedError) HasError() bool {
return ne != nil
}
func (ne NestedError) IsFatal() bool {
return ne != nil && ne.severity == SeverityFatal
}
func (ne NestedError) IsWarning() bool {
return ne != nil && ne.severity == SeverityWarning
}
func errorf(format string, args ...any) NestedError {
return From(fmt.Errorf(format, args...))
}
func fromJSONObject(obj jsonNestedError) (NestedError, bool) {
data, err := json.Marshal(obj)
if err != nil {
return nil, false
}
return FromJSON(data)
}
func (ne NestedError) withError(err NestedError) NestedError {
if ne != nil && err != nil {
ne.extras = append(ne.extras, *err)
@@ -201,7 +230,7 @@ func (ne NestedError) withError(err NestedError) NestedError {
return ne
}
func (ne NestedError) append(msg string) NestedError {
func (ne NestedError) appendMsg(msg string) NestedError {
if ne == nil {
return nil
}
@@ -259,6 +288,8 @@ func (ne NestedError) buildError(level int, prefix string) error {
for _, extra := range ne.extras {
res = errors.Join(res, extra.buildError(level+1, "- "))
}
} else {
res = fmt.Errorf("%w%s", res, sb.String())
}
return res
}

View File

@@ -4,8 +4,8 @@ import (
"errors"
"testing"
. "github.com/yusing/go-proxy/error"
. "github.com/yusing/go-proxy/utils/testing"
. "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestErrorIs(t *testing.T) {
@@ -31,11 +31,11 @@ func TestErrorNestedIs(t *testing.T) {
err = Failure("some reason")
ExpectTrue(t, err.Is(ErrFailure))
ExpectFalse(t, err.Is(ErrAlreadyExist))
ExpectFalse(t, err.Is(ErrDuplicated))
err.With(AlreadyExist("something", ""))
err.With(Duplicated("something", ""))
ExpectTrue(t, err.Is(ErrFailure))
ExpectTrue(t, err.Is(ErrAlreadyExist))
ExpectTrue(t, err.Is(ErrDuplicated))
ExpectFalse(t, err.Is(ErrInvalid))
}

View File

@@ -5,14 +5,14 @@ import (
)
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")
ErrAlreadyExist = stderrors.New("already exist")
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")
)
const fmtSubjectWhat = "%w %v: %q"
@@ -53,8 +53,8 @@ func Missing(subject any) NestedError {
return errorf("%w %v", ErrMissing, subject)
}
func AlreadyExist(subject, what any) NestedError {
return errorf("%v %w: %v", subject, ErrAlreadyExist, what)
func Duplicated(subject, what any) NestedError {
return errorf("%w %v: %v", ErrDuplicated, subject, what)
}
func OutOfRange(subject string, value any) NestedError {

View File

@@ -0,0 +1,32 @@
package http
import (
"mime"
"net/http"
)
type ContentType string
func GetContentType(h http.Header) ContentType {
ct := h.Get("Content-Type")
if ct == "" {
return ""
}
ct, _, err := mime.ParseMediaType(ct)
if err != nil {
return ""
}
return ContentType(ct)
}
func (ct ContentType) IsHTML() bool {
return ct == "text/html" || ct == "application/xhtml+xml"
}
func (ct ContentType) IsJSON() bool {
return ct == "application/json"
}
func (ct ContentType) IsPlainText() bool {
return ct == "text/plain"
}

View File

@@ -0,0 +1,42 @@
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)
}
}
}

View File

@@ -0,0 +1,96 @@
// Modified from Traefik Labs's MIT-licensed code (https://github.com/traefik/traefik/blob/master/pkg/middlewares/response_modifier.go)
// Copyright (c) 2020-2024 Traefik Labs
package http
import (
"bufio"
"fmt"
"net"
"net/http"
)
type ModifyResponseFunc func(*http.Response) error
type ModifyResponseWriter struct {
w http.ResponseWriter
r *http.Request
headerSent bool
code int
modifier ModifyResponseFunc
modified bool
modifierErr error
}
func NewModifyResponseWriter(w http.ResponseWriter, r *http.Request, f ModifyResponseFunc) *ModifyResponseWriter {
return &ModifyResponseWriter{
w: w,
r: r,
modifier: f,
code: http.StatusOK,
}
}
func (w *ModifyResponseWriter) WriteHeader(code int) {
if w.headerSent {
return
}
if code >= http.StatusContinue && code < http.StatusOK {
w.w.WriteHeader(code)
}
defer func() {
w.headerSent = true
w.code = code
}()
if w.modifier == nil || w.modified {
w.w.WriteHeader(code)
return
}
resp := http.Response{
Header: w.w.Header(),
Request: w.r,
}
if err := w.modifier(&resp); err != nil {
w.modifierErr = err
logger.Errorf("error modifying response: %s", err)
w.w.WriteHeader(http.StatusInternalServerError)
return
}
w.modified = true
w.w.WriteHeader(code)
}
func (w *ModifyResponseWriter) Header() http.Header {
return w.w.Header()
}
func (w *ModifyResponseWriter) Write(b []byte) (int, error) {
w.WriteHeader(w.code)
if w.modifierErr != nil {
return 0, w.modifierErr
}
return w.w.Write(b)
}
// Hijack hijacks the connection.
func (w *ModifyResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if h, ok := w.w.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, fmt.Errorf("not a hijacker: %T", w.w)
}
// Flush sends any buffered data to the client.
func (w *ModifyResponseWriter) Flush() {
if flusher, ok := w.w.(http.Flusher); ok {
flusher.Flush()
}
}

View File

@@ -1,9 +1,16 @@
package proxy
// Copyright 2011 The Go Authors.
// Modified from the Go project under the a BSD-style License (https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/net/http/httputil/reverseproxy.go)
// https://cs.opensource.google/go/go/+/master:LICENSE
// A small mod on net/http/httputil/reverseproxy.go
// that doubled the performance
package http
// This is a small mod on net/http/httputil/reverseproxy.go
// that boosts performance in some cases
// and compatible to other modules of this project
// Copyright (c) 2024 yusing
import (
"bytes"
"context"
"errors"
"fmt"
@@ -52,6 +59,21 @@ type ProxyRequest struct {
// 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"]
@@ -104,45 +126,18 @@ type ReverseProxy struct {
// If nil, http.DefaultTransport is used.
Transport http.RoundTripper
// FlushInterval specifies the flush interval
// to flush to the client while copying the
// response body.
// If zero, no periodic flushing is done.
// A negative value means to flush immediately
// after each write to the client.
// The FlushInterval is ignored when ReverseProxy
// recognizes a response as a streaming response, or
// if its ContentLength is -1; for such responses, writes
// are flushed to the client immediately.
// FlushInterval time.Duration
// ErrorLog specifies an optional logger for errors
// that occur when attempting to proxy the request.
// If nil, logging is done via the log package's standard logger.
// ErrorLog *log.Logger
// BufferPool optionally specifies a buffer pool to
// get byte slices for use by io.CopyBuffer when
// copying HTTP response bodies.
// BufferPool BufferPool
// ModifyResponse is an optional function that modifies the
// Response from the backend. It is called if the backend
// returns a response at all, with any HTTP status code.
// If the backend is unreachable, the optional ErrorHandler is
// called without any call to ModifyResponse.
// called before ModifyResponse.
//
// If ModifyResponse returns an error, ErrorHandler is called
// with its error value. If ErrorHandler is nil, its default
// implementation is used.
ModifyResponse func(*http.Response) error
// ErrorHandler is an optional function that handles errors
// reaching the backend or errors from ModifyResponse.
//
// If nil, the default is to log the provided error and return
// a 502 Status Bad Gateway response.
ErrorHandler func(http.ResponseWriter, *http.Request, error)
ServeHTTP http.HandlerFunc
}
// A BufferPool is an interface for getting and returning temporary
@@ -206,36 +201,15 @@ func joinURLPath(a, b *url.URL) (path, rawpath string) {
// },
// }
//
// TODO: headers in ModifyResponse
func NewReverseProxy(target *url.URL, transport http.RoundTripper, entry *ReverseProxyEntry) *ReverseProxy {
// check on init rather than on request
var setHeaders = func(r *http.Request) {}
var hideHeaders = func(r *http.Request) {}
if len(entry.SetHeaders) > 0 {
setHeaders = func(r *http.Request) {
h := entry.SetHeaders.Clone()
for k, vv := range h {
if k == "Host" {
r.Host = vv[0]
} else {
r.Header[k] = vv
}
}
}
func NewReverseProxy(target *url.URL, transport http.RoundTripper) *ReverseProxy {
rp := &ReverseProxy{
Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
}, Transport: transport,
}
if len(entry.HideHeaders) > 0 {
hideHeaders = func(r *http.Request) {
for _, k := range entry.HideHeaders {
r.Header.Del(k)
}
}
}
return &ReverseProxy{Rewrite: func(pr *ProxyRequest) {
rewriteRequestURL(pr.Out, target)
// pr.SetXForwarded()
setHeaders(pr.Out)
hideHeaders(pr.Out)
}, Transport: transport}
rp.ServeHTTP = rp.serveHTTP
return rp
}
func rewriteRequestURL(req *http.Request, target *url.URL) {
@@ -250,6 +224,23 @@ func rewriteRequestURL(req *http.Request, target *url.URL) {
}
}
// 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
// obsoleted RFC 2616 (section 13.5.1) and are used for backward
// compatibility.
var hopHeaders = []string{
"Connection",
"Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"Te", // canonicalized version of "TE"
"Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
"Transfer-Encoding",
"Upgrade",
}
func copyHeader(dst, src http.Header) {
for k, vv := range src {
for _, v := range vv {
@@ -258,9 +249,11 @@ func copyHeader(dst, src http.Header) {
}
}
func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, _ *http.Request, err error) {
logger.Errorf("http: proxy error: %s", err)
rw.WriteHeader(http.StatusBadGateway)
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)
if writeHeader {
rw.WriteHeader(http.StatusBadGateway)
}
}
// modifyResponse conditionally runs the optional ModifyResponse hook
@@ -271,13 +264,13 @@ func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response
}
if err := p.ModifyResponse(res); err != nil {
res.Body.Close()
p.errorHandler(rw, req, err)
p.errorHandler(rw, req, err, true)
return false
}
return true
}
func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
transport := p.Transport
ctx := req.Context()
@@ -325,12 +318,14 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
outreq.Close = false
reqUpType := upgradeType(outreq.Header)
reqUpType := UpgradeType(outreq.Header)
if !IsPrint(reqUpType) {
p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType))
p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType), true)
return
}
RemoveHopByHopHeaders(outreq.Header)
// Issue 21096: tell backend applications that care about trailer support
// that we support trailers. (We do, but we don't go out of our way to
// advertise that unless the incoming client request thought it was worth
@@ -348,9 +343,9 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
outreq.Header.Del("Forwarded")
outreq.Header.Del("X-Forwarded-For")
outreq.Header.Del("X-Forwarded-Host")
outreq.Header.Del("X-Forwarded-Proto")
// outreq.Header.Del("X-Forwarded-For")
// outreq.Header.Del("X-Forwarded-Host")
// outreq.Header.Del("X-Forwarded-Proto")
pr := &ProxyRequest{
In: req,
@@ -385,8 +380,20 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
res, err := transport.RoundTrip(outreq)
if err != nil {
p.errorHandler(rw, outreq, err)
return
p.errorHandler(rw, outreq, err, false)
errMsg := err.Error()
res = &http.Response{
Status: http.StatusText(http.StatusBadGateway),
StatusCode: http.StatusBadGateway,
Proto: outreq.Proto,
ProtoMajor: outreq.ProtoMajor,
ProtoMinor: outreq.ProtoMinor,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader([]byte(errMsg))),
Request: outreq,
ContentLength: int64(len(errMsg)),
TLS: outreq.TLS,
}
}
// Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
@@ -419,16 +426,9 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
_, err = io.Copy(rw, res.Body)
if err != nil {
defer res.Body.Close()
// note: removed
// Since we're streaming the response, if we run into an error all we can do
// is abort the request. Issue 23643: ReverseProxy should use ErrAbortHandler
// on read error while copying body.
// if !shouldPanicOnCopyError(req) {
// p.logf("suppressing panic for copyResponse error in test; copy error: %s", err)
// return
// }
panic(http.ErrAbortHandler)
p.errorHandler(rw, req, err, true)
res.Body.Close()
return
}
res.Body.Close() // close now, instead of defer, to populate res.Trailer
@@ -452,34 +452,52 @@ func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
}
}
func upgradeType(h http.Header) string {
func UpgradeType(h http.Header) string {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return ""
}
return h.Get("Upgrade")
}
// RemoveHopByHopHeaders removes hop-by-hop headers.
func RemoveHopByHopHeaders(h http.Header) {
// RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
for _, f := range h["Connection"] {
for _, sf := range strings.Split(f, ",") {
if sf = textproto.TrimString(sf); sf != "" {
h.Del(sf)
}
}
}
// RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
// This behavior is superseded by the RFC 7230 Connection header, but
// preserve it for backwards compatibility.
for _, f := range hopHeaders {
h.Del(f)
}
}
func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) {
reqUpType := upgradeType(req.Header)
resUpType := upgradeType(res.Header)
reqUpType := UpgradeType(req.Header)
resUpType := UpgradeType(res.Header)
if !IsPrint(resUpType) { // We know reqUpType is ASCII, it's checked by the caller.
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType))
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType), true)
}
if !strings.EqualFold(reqUpType, resUpType) {
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType))
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType), true)
return
}
backConn, ok := res.Body.(io.ReadWriteCloser)
if !ok {
p.errorHandler(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body"))
p.errorHandler(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body"), true)
return
}
rc := http.NewResponseController(rw)
conn, brw, hijackErr := rc.Hijack()
if errors.Is(hijackErr, http.ErrNotSupported) {
p.errorHandler(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw))
p.errorHandler(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw), true)
return
}
@@ -496,7 +514,7 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R
defer close(backConnCloseCh)
if hijackErr != nil {
p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %w", hijackErr))
p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %w", hijackErr), true)
return
}
defer conn.Close()
@@ -506,11 +524,11 @@ func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.R
res.Header = rw.Header()
res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above
if err := res.Write(brw); err != nil {
p.errorHandler(rw, req, fmt.Errorf("response write: %s", err))
p.errorHandler(rw, req, fmt.Errorf("response write: %s", err), true)
return
}
if err := brw.Flush(); err != nil {
p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err))
p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err), true)
return
}
errc := make(chan error, 1)

View File

@@ -0,0 +1,7 @@
package http
import "net/http"
func IsSuccess(status int) bool {
return status >= http.StatusOK && status < http.StatusMultipleChoices
}

View File

@@ -3,6 +3,8 @@ package model
type Config struct {
Providers ProxyProviders `yaml:",flow" json:"providers"`
AutoCert AutoCertConfig `yaml:",flow" json:"autocert"`
ExplicitOnly bool `yaml:"explicit_only" json:"explicit_only"`
MatchDomains []string `yaml:"match_domains" json:"match_domains"`
TimeoutShutdown int `yaml:"timeout_shutdown" json:"timeout_shutdown"`
RedirectToHTTPS bool `yaml:"redirect_to_https" json:"redirect_to_https"`
}
@@ -11,6 +13,6 @@ func DefaultConfig() *Config {
return &Config{
Providers: ProxyProviders{},
TimeoutShutdown: 3,
RedirectToHTTPS: true,
RedirectToHTTPS: false,
}
}

View File

@@ -0,0 +1,151 @@
package model
import (
"fmt"
"strconv"
"strings"
. "github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type (
RawEntry struct {
// raw entry object before validation
// loaded from docker labels or yaml file
Alias string `yaml:"-" json:"-"`
Scheme string `yaml:"scheme" json:"scheme"`
Host string `yaml:"host" json:"host"`
Port string `yaml:"port" json:"port"`
NoTLSVerify bool `yaml:"no_tls_verify" json:"no_tls_verify"` // https proxy only
PathPatterns []string `yaml:"path_patterns" json:"path_patterns"` // http(s) proxy only
Middlewares D.NestedLabelMap `yaml:"middlewares" json:"middlewares"`
/* Docker only */
*D.ProxyProperties `yaml:"-" json:"proxy_properties"`
}
RawEntries = F.Map[string, *RawEntry]
)
var NewProxyEntries = F.NewMapOf[string, *RawEntry]
func (e *RawEntry) FillMissingFields() bool {
isDocker := e.ProxyProperties != nil
if !isDocker {
e.ProxyProperties = &D.ProxyProperties{}
}
lp, pp, extra := e.splitPorts()
if port, ok := ServiceNamePortMapTCP[e.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "tcp"
}
} else if port, ok := ImageNamePortMap[e.ImageName]; ok {
if pp == "" {
pp = strconv.Itoa(port)
}
if e.Scheme == "" {
e.Scheme = "http"
}
} else if pp == "" && e.Scheme == "https" {
pp = "443"
} else if pp == "" {
if p, ok := F.FirstValueOf(e.PrivatePortMapping); ok {
pp = fmt.Sprint(p.PrivatePort)
} else {
pp = "80"
}
}
// replace private port with public port (if any)
if isDocker && e.NetworkMode != "host" {
if p, ok := e.PrivatePortMapping[pp]; ok {
pp = fmt.Sprint(p.PublicPort)
}
if _, ok := e.PublicPortMapping[pp]; !ok { // port is not exposed, but specified
// try to fallback to first public port
if p, ok := F.FirstValueOf(e.PublicPortMapping); ok {
pp = fmt.Sprint(p.PublicPort)
}
// ignore only if it is NOT RUNNING
// because stopped containers
// will have empty port mapping got from docker
if e.Running {
return false
}
}
}
if e.Scheme == "" && isDocker {
if p, ok := e.PublicPortMapping[pp]; ok && p.Type == "udp" {
e.Scheme = "udp"
}
}
if e.Scheme == "" {
if lp != "" {
e.Scheme = "tcp"
} else if strings.HasSuffix(pp, "443") {
e.Scheme = "https"
} else if _, ok := WellKnownHTTPPorts[pp]; ok {
e.Scheme = "http"
} else {
// assume its http
e.Scheme = "http"
}
}
if e.Host == "" {
e.Host = "localhost"
}
if e.IdleTimeout == "" {
e.IdleTimeout = IdleTimeoutDefault
}
if e.WakeTimeout == "" {
e.WakeTimeout = WakeTimeoutDefault
}
if e.StopTimeout == "" {
e.StopTimeout = StopTimeoutDefault
}
if e.StopMethod == "" {
e.StopMethod = StopMethodDefault
}
e.Port = joinPorts(lp, pp, extra)
return true
}
func (e *RawEntry) splitPorts() (lp string, pp string, extra string) {
portSplit := strings.Split(e.Port, ":")
if len(portSplit) == 1 {
pp = portSplit[0]
} else {
lp = portSplit[0]
pp = portSplit[1]
}
if len(portSplit) > 2 {
extra = strings.Join(portSplit[2:], ":")
}
return
}
func joinPorts(lp string, pp string, extra string) string {
s := make([]string, 0, 3)
if lp != "" {
s = append(s, lp)
}
if pp != "" {
s = append(s, pp)
}
if extra != "" {
s = append(s, extra)
}
return strings.Join(s, ":")
}

View File

@@ -2,13 +2,13 @@ package proxy
import (
"fmt"
"net/http"
"net/url"
"time"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
T "github.com/yusing/go-proxy/proxy/fields"
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"
)
type (
@@ -18,8 +18,7 @@ type (
URL *url.URL
NoTLSVerify bool
PathPatterns T.PathPatterns
SetHeaders http.Header
HideHeaders []string
Middlewares D.NestedLabelMap
/* Docker only */
IdleTimeout time.Duration
@@ -54,7 +53,7 @@ func ValidateEntry(m *M.RawEntry) (any, E.NestedError) {
}
var entry any
e := E.NewBuilder("error validating proxy entry")
e := E.NewBuilder("error validating entry")
if scheme.IsStream() {
entry = validateStreamEntry(m, e)
} else {
@@ -78,9 +77,6 @@ func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry
pathPatterns, err := T.ValidatePathPatterns(m.PathPatterns)
b.Add(err)
setHeaders, err := T.ValidateHTTPHeaders(m.SetHeaders)
b.Add(err)
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, port)))
b.Add(err)
@@ -111,8 +107,7 @@ func validateRPEntry(m *M.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry
URL: url,
NoTLSVerify: m.NoTLSVerify,
PathPatterns: pathPatterns,
SetHeaders: setHeaders,
HideHeaders: m.HideHeaders,
Middlewares: m.Middlewares,
IdleTimeout: idleTimeout,
WakeTimeout: wakeTimeout,
StopMethod: stopMethod,

View File

@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
func ValidateHTTPHeaders(headers map[string]string) (http.Header, E.NestedError) {

View File

@@ -0,0 +1,12 @@
package fields
import (
E "github.com/yusing/go-proxy/internal/error"
)
type Host string
type Subdomain = Alias
func ValidateHost[String ~string](s String) (Host, E.NestedError) {
return Host(s), nil
}

View File

@@ -1,7 +1,7 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type PathMode string

View File

@@ -3,7 +3,7 @@ package fields
import (
"regexp"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type PathPattern string

View File

@@ -3,8 +3,8 @@ package fields
import (
"testing"
E "github.com/yusing/go-proxy/error"
U "github.com/yusing/go-proxy/utils/testing"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils/testing"
)
var validPatterns = []string{

View File

@@ -3,13 +3,13 @@ package fields
import (
"strconv"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type Port int
func ValidatePort(v string) (Port, E.NestedError) {
p, err := strconv.Atoi(v)
func ValidatePort[String ~string](v String) (Port, E.NestedError) {
p, err := strconv.Atoi(string(v))
if err != nil {
return ErrPort, E.Invalid("port number", v).With(err)
}

View File

@@ -1,12 +1,12 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type Scheme string
func NewScheme(s string) (Scheme, E.NestedError) {
func NewScheme[String ~string](s String) (Scheme, E.NestedError) {
switch s {
case "http", "https", "tcp", "udp":
return Scheme(s), nil

View File

@@ -1,7 +1,7 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type Signal string

View File

@@ -1,7 +1,7 @@
package fields
import (
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type StopMethod string

View File

@@ -3,8 +3,8 @@ package fields
import (
"strings"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
)
type StreamPort struct {
@@ -26,18 +26,19 @@ func ValidateStreamPort(p string) (StreamPort, E.NestedError) {
listeningPort, err := ValidatePort(split[0])
if err != nil {
return ErrStreamPort, err
return ErrStreamPort, err.Subject("listening port")
}
proxyPort, err := ValidatePort(split[1])
if err.Is(E.ErrOutOfRange) {
return ErrStreamPort, err
return ErrStreamPort, err.Subject("proxy port")
} else if proxyPort == 0 {
return ErrStreamPort, E.Invalid("stream port", p).With("proxy port cannot be 0")
return ErrStreamPort, E.Invalid("proxy port", p)
} else if err != nil {
proxyPort, err = parseNameToPort(split[1])
if err != nil {
return ErrStreamPort, E.Invalid("stream port", p).With(proxyPort)
return ErrStreamPort, E.Invalid("proxy port", proxyPort)
}
}

View File

@@ -3,8 +3,8 @@ package fields
import (
"testing"
E "github.com/yusing/go-proxy/error"
. "github.com/yusing/go-proxy/utils/testing"
E "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var validPorts = []string{

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"strings"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
type StreamScheme struct {

View File

@@ -3,7 +3,7 @@ package fields
import (
"time"
E "github.com/yusing/go-proxy/error"
E "github.com/yusing/go-proxy/internal/error"
)
func ValidateDurationPostitive(value string) (time.Duration, E.NestedError) {

View File

@@ -6,25 +6,28 @@ import (
"strconv"
"strings"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
R "github.com/yusing/go-proxy/route"
W "github.com/yusing/go-proxy/watcher"
"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"
W "github.com/yusing/go-proxy/internal/watcher"
)
type DockerProvider struct {
dockerHost, hostname string
ExplicitOnly bool
}
var AliasRefRegex = regexp.MustCompile(`\$\d+`)
var AliasRefRegex = regexp.MustCompile(`#\d+`)
var AliasRefRegexOld = regexp.MustCompile(`\$\d+`)
func DockerProviderImpl(dockerHost string) (ProviderImpl, E.NestedError) {
func DockerProviderImpl(dockerHost string, explicitOnly bool) (ProviderImpl, E.NestedError) {
hostname, err := D.ParseDockerHostname(dockerHost)
if err.HasError() {
return nil, err
}
return &DockerProvider{dockerHost: dockerHost, hostname: hostname}, nil
return &DockerProvider{dockerHost, hostname, explicitOnly}, nil
}
func (p *DockerProvider) String() string {
@@ -103,7 +106,7 @@ func (p *DockerProvider) OnEvent(event W.Event, routes R.Routes) (res EventResul
entries.RangeAll(func(alias string, entry *M.RawEntry) {
if routes.Has(alias) {
b.Add(E.AlreadyExist("alias", alias))
b.Add(E.Duplicated("alias", alias))
} else {
if route, err := R.NewRoute(entry); err.HasError() {
b.Add(err)
@@ -120,8 +123,13 @@ 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) (M.RawEntries, E.NestedError) {
entries := M.NewProxyEntries()
func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (entries M.RawEntries, _ E.NestedError) {
entries = M.NewProxyEntries()
if container.IsExcluded ||
!container.IsExplicit && p.ExplicitOnly {
return
}
// init entries map for all aliases
for _, a := range container.Aliases {
@@ -137,30 +145,11 @@ func (p *DockerProvider) entriesFromContainerLabels(container D.Container) (M.Ra
errors.Add(p.applyLabel(container, entries, key, val))
}
// selecting correct host port
replacePrivPorts := func() {
if container.HostConfig.NetworkMode != "host" {
entries.RangeAll(func(_ string, entry *M.RawEntry) {
entryPortSplit := strings.Split(entry.Port, ":")
n := len(entryPortSplit)
// if the port matches the proxy port, replace it with the public port
if p, ok := container.PrivatePortMapping[entryPortSplit[n-1]]; ok {
entryPortSplit[n-1] = fmt.Sprint(p.PublicPort)
entry.Port = strings.Join(entryPortSplit, ":")
}
})
}
}
replacePrivPorts()
// remove all entries that failed to fill in missing fields
entries.RemoveAll(func(re *M.RawEntry) bool {
return !re.FillMissingFields()
})
// do it again since the port may got filled in
replacePrivPorts()
return entries, errors.Build().Subject(container.ContainerName)
}
@@ -168,6 +157,20 @@ func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries,
b := E.NewBuilder("errors in label %s", key)
defer b.To(&res)
refErr := E.NewBuilder("errors parsing alias references")
replaceIndexRef := func(ref string) string {
index, err := strconv.Atoi(ref[1:])
if err != nil {
refErr.Add(E.Invalid("integer", ref))
return ref
}
if index < 1 || index > len(container.Aliases) {
refErr.Add(E.OutOfRange("index", ref))
return ref
}
return container.Aliases[index-1]
}
lbl, err := D.ParseLabel(key, val)
if err.HasError() {
b.Add(err.Subject(key))
@@ -179,22 +182,14 @@ func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries,
// apply label for all aliases
entries.RangeAll(func(a string, e *M.RawEntry) {
if err = D.ApplyLabel(e, lbl); err.HasError() {
b.Add(err.Subject(lbl.Target))
b.Add(err.Subjectf("alias %s", lbl.Target))
}
})
} else {
refErr := E.NewBuilder("errors parsing alias references")
lbl.Target = AliasRefRegex.ReplaceAllStringFunc(lbl.Target, func(ref string) string {
index, err := strconv.Atoi(ref[1:])
if err != nil {
refErr.Add(E.Invalid("integer", ref))
return ref
}
if index < 1 || index > len(container.Aliases) {
refErr.Add(E.OutOfRange("index", ref))
return ref
}
return container.Aliases[index-1]
lbl.Target = AliasRefRegex.ReplaceAllStringFunc(lbl.Target, replaceIndexRef)
lbl.Target = AliasRefRegexOld.ReplaceAllStringFunc(lbl.Target, func(s string) string {
logrus.Warnf("%q should now be %q, old syntax will be removed in a future version", lbl, strings.ReplaceAll(lbl.String(), "$", "#"))
return replaceIndexRef(s)
})
if refErr.HasError() {
b.Add(refErr.Build())
@@ -206,7 +201,7 @@ func (p *DockerProvider) applyLabel(container D.Container, entries M.RawEntries,
return
}
if err = D.ApplyLabel(config, lbl); err.HasError() {
b.Add(err.Subject(lbl.Target))
b.Add(err.Subjectf("alias %s", lbl.Target))
}
}
return

View File

@@ -5,13 +5,13 @@ import (
"testing"
"github.com/docker/docker/api/types"
"github.com/yusing/go-proxy/common"
D "github.com/yusing/go-proxy/docker"
E "github.com/yusing/go-proxy/error"
P "github.com/yusing/go-proxy/proxy"
T "github.com/yusing/go-proxy/proxy/fields"
"github.com/yusing/go-proxy/internal/common"
D "github.com/yusing/go-proxy/internal/docker"
E "github.com/yusing/go-proxy/internal/error"
P "github.com/yusing/go-proxy/internal/proxy"
T "github.com/yusing/go-proxy/internal/proxy/fields"
. "github.com/yusing/go-proxy/utils/testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
var dummyNames = []string{"/a"}
@@ -27,41 +27,36 @@ func TestApplyLabelFieldValidity(t *testing.T) {
"POST /upload/{$}",
"GET /static",
}
setHeaders := `
X_Custom_Header1: value1
X_Custom_Header1: value2
X_Custom_Header2: value3
`[1:]
setHeadersExpect := map[string]string{
"X_Custom_Header1": "value1, value2",
"X_Custom_Header2": "value3",
}
hideHeaders := `
- X-Custom-Header1
- X-Custom-Header2
`[1:]
hideHeadersExpect := []string{
"X-Custom-Header1",
"X-Custom-Header2",
middlewaresExpect := D.NestedLabelMap{
"middleware1": {
"prop1": "value1",
"prop2": "value2",
},
"middleware2": {
"prop3": "value3",
"prop4": "value4",
},
}
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
D.LabelIdleTimeout: common.IdleTimeoutDefault,
D.LabelStopMethod: common.StopMethodDefault,
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
"proxy.*.port": "4567",
"proxy.a.no_tls_verify": "true",
"proxy.a.path_patterns": pathPatterns,
"proxy.a.set_headers": setHeaders,
"proxy.a.hide_headers": hideHeaders,
D.LabelAliases: "a,b",
D.LabelIdleTimeout: common.IdleTimeoutDefault,
D.LabelStopMethod: common.StopMethodDefault,
D.LabelStopSignal: "SIGTERM",
D.LabelStopTimeout: common.StopTimeoutDefault,
D.LabelWakeTimeout: common.WakeTimeoutDefault,
"proxy.*.no_tls_verify": "true",
"proxy.*.scheme": "https",
"proxy.*.host": "app",
"proxy.*.port": "4567",
"proxy.a.no_tls_verify": "true",
"proxy.a.path_patterns": pathPatterns,
"proxy.a.middlewares.middleware1.prop1": "value1",
"proxy.a.middlewares.middleware1.prop2": "value2",
"proxy.a.middlewares.middleware2.prop3": "value3",
"proxy.a.middlewares.middleware2.prop4": "value4",
},
Ports: []types.Port{
{Type: "tcp", PrivatePort: 4567, PublicPort: 8888},
@@ -88,11 +83,8 @@ X_Custom_Header2: value3
ExpectDeepEqual(t, a.PathPatterns, pathPatternsExpect)
ExpectEqual(t, len(b.PathPatterns), 0)
ExpectDeepEqual(t, a.SetHeaders, setHeadersExpect)
ExpectEqual(t, len(b.SetHeaders), 0)
ExpectDeepEqual(t, a.HideHeaders, hideHeadersExpect)
ExpectEqual(t, len(b.HideHeaders), 0)
ExpectDeepEqual(t, a.Middlewares, middlewaresExpect)
ExpectEqual(t, len(b.Middlewares), 0)
ExpectEqual(t, a.IdleTimeout, common.IdleTimeoutDefault)
ExpectEqual(t, b.IdleTimeout, common.IdleTimeoutDefault)
@@ -140,7 +132,8 @@ func TestApplyLabel(t *testing.T) {
ExpectEqual(t, b.Scheme, "http")
ExpectEqual(t, b.Port, "1234")
ExpectEqual(t, c.Scheme, "https")
ExpectEqual(t, c.Port, "1111")
// map does not necessary follow the order above
ExpectEqualAny(t, c.Port, []string{"1111", "1234"})
}
func TestApplyLabelWithRef(t *testing.T) {
@@ -149,11 +142,11 @@ func TestApplyLabelWithRef(t *testing.T) {
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b,c",
"proxy.$1.host": "localhost",
"proxy.*.port": "1111",
"proxy.$1.port": "4444",
"proxy.$2.port": "9999",
"proxy.$3.scheme": "https",
"proxy.#1.host": "localhost",
"proxy.#1.port": "4444",
"proxy.#2.port": "9999",
"proxy.#3.port": "1111",
"proxy.#3.scheme": "https",
},
Ports: []types.Port{
{Type: "tcp", PrivatePort: 3333, PublicPort: 9999},
@@ -182,8 +175,8 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
"proxy.$1.host": "localhost",
"proxy.$4.scheme": "https",
"proxy.#1.host": "localhost",
"proxy.#4.scheme": "https",
}}, "")
_, err := p.entriesFromContainerLabels(c)
ExpectError(t, E.ErrOutOfRange, err.Error())
@@ -193,7 +186,7 @@ func TestApplyLabelWithRefIndexError(t *testing.T) {
Names: dummyNames,
Labels: map[string]string{
D.LabelAliases: "a,b",
"proxy.$0.host": "localhost",
"proxy.#0.host": "localhost",
}}, ""))
ExpectError(t, E.ErrOutOfRange, err.Error())
ExpectTrue(t, strings.Contains(err.String(), "index out of range"))
@@ -249,7 +242,9 @@ func TestImplicitExclude(t *testing.T) {
Labels: map[string]string{
D.LabelAliases: "a",
"proxy.a.no_tls_verify": "true",
}}, ""))
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
_, ok := entries.Load("a")
@@ -264,6 +259,7 @@ func TestImplicitExcludeNoExposedPort(t *testing.T) {
Ports: []types.Port{
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
State: "running",
}, ""))
ExpectNoError(t, err.Error())
@@ -271,7 +267,7 @@ func TestImplicitExcludeNoExposedPort(t *testing.T) {
ExpectFalse(t, ok)
}
func TestExcludeNonExposedPort(t *testing.T) {
func TestNotExcludeSpecifiedPort(t *testing.T) {
var p DockerProvider
entries, err := p.entriesFromContainerLabels(D.FromDocker(&types.Container{
Image: "redis",
@@ -280,13 +276,13 @@ func TestExcludeNonExposedPort(t *testing.T) {
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379", // should be excluded even specified
"proxy.redis.port": "6379:6379", // but specified in label
},
}, ""))
ExpectNoError(t, err.Error())
_, ok := entries.Load("redis")
ExpectFalse(t, ok)
ExpectTrue(t, ok)
}
func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
@@ -298,7 +294,7 @@ func TestNotExcludeNonExposedPortHostNetwork(t *testing.T) {
{Type: "tcp", PrivatePort: 6379, PublicPort: 0}, // not exposed
},
Labels: map[string]string{
"proxy.redis.port": "6379:6379", // should be excluded even specified
"proxy.redis.port": "6379:6379",
},
}
cont.HostConfig.NetworkMode = "host"

View File

@@ -5,12 +5,12 @@ import (
"os"
"path"
"github.com/yusing/go-proxy/common"
E "github.com/yusing/go-proxy/error"
M "github.com/yusing/go-proxy/models"
R "github.com/yusing/go-proxy/route"
U "github.com/yusing/go-proxy/utils"
W "github.com/yusing/go-proxy/watcher"
"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"
U "github.com/yusing/go-proxy/internal/utils"
W "github.com/yusing/go-proxy/internal/watcher"
)
type FileProvider struct {
@@ -94,5 +94,5 @@ func (p *FileProvider) LoadRoutesImpl() (routes R.Routes, res E.NestedError) {
}
func (p *FileProvider) NewWatcher() W.Watcher {
return W.NewFileWatcher(p.fileName)
return W.NewConfigFileWatcher(p.fileName)
}

View File

@@ -5,9 +5,9 @@ import (
"path"
"github.com/sirupsen/logrus"
E "github.com/yusing/go-proxy/error"
R "github.com/yusing/go-proxy/route"
W "github.com/yusing/go-proxy/watcher"
E "github.com/yusing/go-proxy/internal/error"
R "github.com/yusing/go-proxy/internal/route"
W "github.com/yusing/go-proxy/internal/watcher"
)
type (
@@ -56,6 +56,9 @@ func newProvider(name string, t ProviderType) *Provider {
func NewFileProvider(filename string) (p *Provider, err E.NestedError) {
name := path.Base(filename)
if name == "" {
return nil, E.Invalid("file name", "empty")
}
p = newProvider(name, ProviderTypeFile)
p.ProviderImpl, err = FileProviderImpl(filename)
if err != nil {
@@ -66,8 +69,17 @@ func NewFileProvider(filename string) (p *Provider, err E.NestedError) {
}
func NewDockerProvider(name string, dockerHost string) (p *Provider, err E.NestedError) {
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)
p.ProviderImpl, err = DockerProviderImpl(dockerHost, explicitOnly)
if err != nil {
return nil, err
}

View File

@@ -1,21 +1,23 @@
package route
import (
"crypto/tls"
"net"
"fmt"
"sync"
"time"
"net/http"
"net/url"
"strings"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/docker/idlewatcher"
E "github.com/yusing/go-proxy/error"
P "github.com/yusing/go-proxy/proxy"
PT "github.com/yusing/go-proxy/proxy/fields"
F "github.com/yusing/go-proxy/utils/functional"
"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"
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"
)
type (
@@ -26,7 +28,7 @@ type (
entry *P.ReverseProxyEntry
mux *http.ServeMux
handler *P.ReverseProxy
handler *ReverseProxy
regIdleWatcher func() E.NestedError
unregIdleWatcher func()
@@ -36,18 +38,41 @@ type (
SubdomainKey = PT.Alias
)
var (
findMuxFunc = findMuxAnyDomain
httpRoutes = F.NewMapOf[SubdomainKey, *HTTPRoute]()
httpRoutesMu sync.Mutex
globalMux = http.NewServeMux() // TODO: support regex subdomain matching
)
func SetFindMuxDomains(domains []string) {
if len(domains) == 0 {
findMuxFunc = findMuxAnyDomain
} else {
findMuxFunc = findMuxByDomains(domains)
}
}
func NewHTTPRoute(entry *P.ReverseProxyEntry) (*HTTPRoute, E.NestedError) {
var trans *http.Transport
var regIdleWatcher func() E.NestedError
var unregIdleWatcher func()
if entry.NoTLSVerify {
trans = transportNoTLS.Clone()
trans = common.DefaultTransportNoTLS.Clone()
} else {
trans = transport.Clone()
trans = common.DefaultTransport.Clone()
}
rp := P.NewReverseProxy(entry.URL, trans, entry)
rp := NewReverseProxy(entry.URL, trans)
if len(entry.Middlewares) > 0 {
err := middleware.PatchReverseProxy(rp, entry.Middlewares)
if err != nil {
return nil, err
}
}
if entry.UseIdleWatcher() {
// allow time for response header up to `WakeTimeout`
@@ -74,7 +99,7 @@ func NewHTTPRoute(entry *P.ReverseProxyEntry) (*HTTPRoute, E.NestedError) {
_, exists := httpRoutes.Load(entry.Alias)
if exists {
return nil, E.AlreadyExist("HTTPRoute alias", entry.Alias)
return nil, E.Duplicated("HTTPRoute alias", entry.Alias)
}
r := &HTTPRoute{
@@ -94,11 +119,16 @@ func (r *HTTPRoute) String() string {
}
func (r *HTTPRoute) Start() E.NestedError {
if r.mux != nil {
return nil
}
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
if r.regIdleWatcher != nil {
if err := r.regIdleWatcher(); err.HasError() {
r.unregIdleWatcher = nil
return err
}
}
@@ -113,6 +143,10 @@ func (r *HTTPRoute) Start() E.NestedError {
}
func (r *HTTPRoute) Stop() E.NestedError {
if r.mux == nil {
return nil
}
httpRoutesMu.Lock()
defer httpRoutesMu.Unlock()
@@ -135,43 +169,58 @@ func (u *URL) MarshalText() (text []byte, err error) {
}
func ProxyHandler(w http.ResponseWriter, r *http.Request) {
mux, err := findMux(r.Host)
mux, err := findMuxFunc(r.Host)
if err != nil {
err = E.Failure("request").
Subjectf("%s %s%s", r.Method, r.Host, r.URL.Path).
With(err)
http.Error(w, err.String(), http.StatusNotFound)
logrus.Error(err)
if !middleware.ServeStaticErrorPageFile(w, r) {
logrus.Error(E.Failure("request").
Subjectf("%s %s", r.Method, r.URL.String()).
With(err))
errorPage, ok := error_page.GetErrorPageByStatus(http.StatusNotFound)
if ok {
w.WriteHeader(http.StatusNotFound)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(errorPage)
} else {
http.Error(w, err.Error(), http.StatusNotFound)
}
}
return
}
mux.ServeHTTP(w, r)
}
func findMux(host string) (*http.ServeMux, E.NestedError) {
sd := strings.Split(host, ".")[0]
func findMuxAnyDomain(host string) (*http.ServeMux, 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
}
return nil, E.NotExist("route", sd)
return nil, fmt.Errorf("no such route: %s", sd)
}
var (
defaultDialer = net.Dialer{
Timeout: 60 * time.Second,
KeepAlive: 60 * time.Second,
}
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: defaultDialer.DialContext,
MaxIdleConnsPerHost: 1000,
}
transportNoTLS = func() *http.Transport {
var clone = transport.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
func findMuxByDomains(domains []string) func(host string) (*http.ServeMux, error) {
return func(host string) (*http.ServeMux, error) {
var subdomain string
httpRoutes = F.NewMapOf[SubdomainKey, *HTTPRoute]()
httpRoutesMu sync.Mutex
globalMux = http.NewServeMux()
)
for _, domain := range domains {
if !strings.HasPrefix(domain, ".") {
domain = "." + domain
}
subdomain = strings.TrimSuffix(host, domain)
if len(subdomain) < len(host) {
break
}
}
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
}
return nil, fmt.Errorf("no such route: %s", subdomain)
}
}

View File

@@ -0,0 +1,75 @@
package middleware
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"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"
)
var CustomErrorPage = &Middleware{
before: func(next http.Handler, w ResponseWriter, r *Request) {
if !ServeStaticErrorPageFile(w, r) {
next.ServeHTTP(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()) {
errorPage, ok := error_page.GetErrorPageByStatus(resp.StatusCode)
if ok {
errPageLogger.Debugf("error page for status %d loaded", resp.StatusCode)
io.Copy(io.Discard, resp.Body) // drain the original body
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(errorPage))
resp.ContentLength = int64(len(errorPage))
resp.Header.Set("Content-Length", fmt.Sprint(len(errorPage)))
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
} else {
errPageLogger.Errorf("unable to load error page for status %d", resp.StatusCode)
}
return nil
}
return nil
},
}
func ServeStaticErrorPageFile(w http.ResponseWriter, r *http.Request) bool {
path := r.URL.Path
if path != "" && path[0] != '/' {
path = "/" + path
}
if strings.HasPrefix(path, common.StaticFilePathPrefix) {
filename := path[len(common.StaticFilePathPrefix):]
file, ok := error_page.GetStaticFile(filename)
if !ok {
errPageLogger.Errorf("unable to load resource %s", filename)
return false
} else {
ext := filepath.Ext(filename)
switch ext {
case ".html":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
case ".js":
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
case ".css":
w.Header().Set("Content-Type", "text/css; charset=utf-8")
default:
errPageLogger.Errorf("unexpected file type %q for %s", ext, filename)
}
w.Write(file)
return true
}
}
return false
}
var errPageLogger = logrus.WithField("middleware", "error_page")

View File

@@ -0,0 +1,249 @@
// Modified from Traefik Labs's MIT-licensed code (https://github.com/traefik/traefik/blob/master/pkg/middlewares/auth/forward.go)
// Copyright (c) 2020-2024 Traefik Labs
// Copyright (c) 2024 yusing
package middleware
import (
"io"
"net"
"net/http"
"net/url"
"slices"
"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"
)
type (
forwardAuth struct {
*forwardAuthOpts
m *Middleware
client http.Client
}
forwardAuthOpts struct {
Address string
TrustForwardHeader bool
AuthResponseHeaders []string
AddAuthCookiesToResponse []string
}
)
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
}
func (fa *forwardAuth) forward(next http.Handler, w ResponseWriter, req *Request) {
gpHTTP.RemoveHop(req.Header)
faReq, err := http.NewRequestWithContext(
req.Context(),
http.MethodGet,
fa.Address,
nil,
)
if err != nil {
faLogger.Debugf("new request err to %s: %s", fa.Address, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
gpHTTP.CopyHeader(faReq.Header, req.Header)
gpHTTP.RemoveHop(faReq.Header)
gpHTTP.FilterHeaders(faReq.Header, fa.AuthResponseHeaders)
fa.setAuthHeaders(req, faReq)
faResp, err := fa.client.Do(faReq)
if err != nil {
faLogger.Debugf("failed to call %s: %s", fa.Address, err)
w.WriteHeader(http.StatusInternalServerError)
return
}
defer faResp.Body.Close()
body, err := io.ReadAll(faResp.Body)
if err != nil {
faLogger.Debugf("failed to read response body from %s: %s", fa.Address, 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())
redirectURL, err := faResp.Location()
if err != nil {
faLogger.Debugf("failed to get location from %s: %s", fa.Address, err)
w.WriteHeader(http.StatusInternalServerError)
return
} else if redirectURL.String() != "" {
w.Header().Set("Location", redirectURL.String())
}
w.WriteHeader(faResp.StatusCode)
if _, err = w.Write(body); err != nil {
faLogger.Debugf("failed to write response body from %s: %s", fa.Address, err)
}
return
}
for _, key := range fa.AuthResponseHeaders {
key := http.CanonicalHeaderKey(key)
req.Header.Del(key)
if len(faResp.Header[key]) > 0 {
req.Header[key] = append([]string(nil), faResp.Header[key]...)
}
}
req.RequestURI = req.URL.RequestURI()
authCookies := faResp.Cookies()
if len(authCookies) == 0 {
next.ServeHTTP(w, req)
return
}
next.ServeHTTP(gpHTTP.NewModifyResponseWriter(w, req, func(resp *Response) error {
fa.setAuthCookies(resp, authCookies)
return nil
}), req)
}
func (fa *forwardAuth) setAuthCookies(resp *Response, authCookies []*Cookie) {
if len(fa.AddAuthCookiesToResponse) == 0 {
return
}
cookies := resp.Cookies()
resp.Header.Del("Set-Cookie")
for _, cookie := range cookies {
if !slices.Contains(fa.AddAuthCookiesToResponse, cookie.Name) {
// this cookie is not an auth cookie, so add it back
resp.Header.Add("Set-Cookie", cookie.String())
}
}
for _, cookie := range authCookies {
if slices.Contains(fa.AddAuthCookiesToResponse, cookie.Name) {
// this cookie is an auth cookie, so add to resp
resp.Header.Add("Set-Cookie", cookie.String())
}
}
}
func (fa *forwardAuth) setAuthHeaders(req, faReq *Request) {
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
if fa.TrustForwardHeader {
if prior, ok := req.Header[xForwardedFor]; ok {
clientIP = strings.Join(prior, ", ") + ", " + clientIP
}
}
faReq.Header.Set(xForwardedFor, clientIP)
}
xMethod := req.Header.Get(xForwardedMethod)
switch {
case xMethod != "" && fa.TrustForwardHeader:
faReq.Header.Set(xForwardedMethod, xMethod)
case req.Method != "":
faReq.Header.Set(xForwardedMethod, req.Method)
default:
faReq.Header.Del(xForwardedMethod)
}
xfp := req.Header.Get(xForwardedProto)
switch {
case xfp != "" && fa.TrustForwardHeader:
faReq.Header.Set(xForwardedProto, xfp)
case req.TLS != nil:
faReq.Header.Set(xForwardedProto, "https")
default:
faReq.Header.Set(xForwardedProto, "http")
}
if xfp := req.Header.Get(xForwardedPort); xfp != "" && fa.TrustForwardHeader {
faReq.Header.Set(xForwardedPort, xfp)
}
xfh := req.Header.Get(xForwardedHost)
switch {
case xfh != "" && fa.TrustForwardHeader:
faReq.Header.Set(xForwardedHost, xfh)
case req.Host != "":
faReq.Header.Set(xForwardedHost, req.Host)
default:
faReq.Header.Del(xForwardedHost)
}
xfURI := req.Header.Get(xForwardedURI)
switch {
case xfURI != "" && fa.TrustForwardHeader:
faReq.Header.Set(xForwardedURI, xfURI)
case req.URL.RequestURI() != "":
faReq.Header.Set(xForwardedURI, req.URL.RequestURI())
default:
faReq.Header.Del(xForwardedURI)
}
}

View File

@@ -0,0 +1,145 @@
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

@@ -0,0 +1,45 @@
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]
}
}
}

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