Compare commits

..

1 Commits

Author SHA1 Message Date
default
d15ea3cefe dependencies upgrade 2024-05-29 16:46:07 +00:00
217 changed files with 6549 additions and 12972 deletions

View File

@@ -1,128 +1,14 @@
name: Docker Image CI
on:
push:
tags: ["*"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
push:
tags:
- "*"
jobs:
build:
name: Build multi-platform Docker image
runs-on: ubuntu-22.04
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
build-args: |
VERSION=${{ github.ref_name }}
- 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: ubuntu-22.04
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 }}
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Build and Push Container to ghcr.io
uses: GlueOps/github-actions-build-push-containers@v0.3.7
with:
tags: latest,${{ github.ref_name }}

30
.github/workflows/go.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.22.1"
- name: Build
run: make build
- name: Release
uses: softprops/action-gh-release@v2
with:
files: bin/go-proxy
#- name: Test
# run: go test -v ./...

22
.gitignore vendored
View File

@@ -1,24 +1,10 @@
compose.yml
*.compose.yml
config*/
certs*/
config/
certs/
bin/
error_pages/
templates/codemirror/
logs/
log/
.vscode/settings.json
go.work.sum
!cmd/**/
!internal/**/
todo.md
.*.swp
.aider*
mtrace.json
.env
.vscode/settings.json

View File

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

View File

@@ -1,136 +0,0 @@
run:
timeout: 10m
linters-settings:
govet:
enable-all: true
disable:
- shadow
- fieldalignment
gocyclo:
min-complexity: 14
goconst:
min-len: 3
min-occurrences: 4
misspell:
locale: US
funlen:
lines: -1
statements: 120
forbidigo:
forbid:
- ^print(ln)?$
godox:
keywords:
- FIXME
tagalign:
align: false
sort: true
order:
- description
- json
- toml
- yaml
- yml
- label
- label-slice-as-struct
- file
- kv
- export
stylecheck:
dot-import-whitelist:
- github.com/yusing/go-proxy/internal/utils/testing # go tests only
- github.com/yusing/go-proxy/internal/api/v1/utils # api only
revive:
rules:
- name: struct-tag
- name: blank-imports
- name: context-as-argument
- name: context-keys-type
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
- name: increment-decrement
- name: var-naming
- name: var-declaration
- name: package-comments
disabled: true
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
- name: indent-error-flow
- name: errorf
- name: empty-block
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
gomoddirectives:
replace-allow-list:
- github.com/abbot/go-http-auth
- github.com/gorilla/mux
- github.com/mailgun/minheap
- github.com/mailgun/multibuf
- github.com/jaguilar/vt100
- github.com/cucumber/godog
- github.com/http-wasm/http-wasm-host-go
testifylint:
disable:
- suite-dont-use-pkg
- require-error
- go-require
staticcheck:
checks:
- all
- -SA1019
errcheck:
exclude-functions:
- fmt.Fprintln
linters:
enable-all: true
disable:
- execinquery # deprecated
- gomnd # deprecated
- sqlclosecheck # not relevant (SQL)
- rowserrcheck # not relevant (SQL)
- cyclop # duplicate of gocyclo
- depguard # Not relevant
- nakedret # Too strict
- lll # Not relevant
- gocyclo # FIXME must be fixed
- gocognit # Too strict
- nestif # Too many false-positive.
- prealloc # Too many false-positive.
- makezero # Not relevant
- dupl # Too strict
- gosec # Too strict
- gochecknoinits
- gochecknoglobals
- wsl # Too strict
- nlreturn # Not relevant
- mnd # Too strict
- testpackage # Too strict
- tparallel # Not relevant
- paralleltest # Not relevant
- exhaustive # Not relevant
- exhaustruct # Not relevant
- err113 # Too strict
- wrapcheck # Too strict
- noctx # Too strict
- bodyclose # too many false-positive
- forcetypeassert # Too strict
- tagliatelle # Too strict
- varnamelen # Not relevant
- nilnil # Not relevant
- ireturn # Not relevant
- contextcheck # too many false-positive
- containedctx # too many false-positive
- maintidx # kind of duplicate of gocyclo
- nonamedreturns # Too strict
- gosmopolitan # not relevant
- exportloopref # Not relevant since go1.22

9
.trunk/.gitignore vendored
View File

@@ -1,9 +0,0 @@
*out
*logs
*actions
*notifications
*tools
plugins
user_trunk.yaml
user.yaml
tmp

View File

@@ -1,41 +0,0 @@
# This file controls the behavior of Trunk: https://docs.trunk.io/cli
# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml
version: 0.1
cli:
version: 1.22.6
# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins)
plugins:
sources:
- id: trunk
ref: v1.6.3
uri: https://github.com/trunk-io/plugins
# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes)
runtimes:
enabled:
- node@18.12.1
- python@3.10.8
- go@1.23.2
# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration)
lint:
enabled:
- hadolint@2.12.0
- actionlint@1.7.3
- checkov@3.2.257
- git-diff-check
- gofmt@1.20.4
- golangci-lint@1.61.0
- markdownlint@0.42.0
- osv-scanner@1.9.0
- oxipng@9.1.2
- prettier@3.3.3
- shellcheck@0.10.0
- shfmt@3.6.0
- trufflehog@3.82.7
- yamllint@1.35.1
actions:
disabled:
- trunk-announce
- trunk-check-pre-push
- trunk-fmt-pre-commit
enabled:
- trunk-upgrade-available

View File

@@ -1,11 +1,12 @@
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml"
]
}
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml"
]
}
}

View File

@@ -1,58 +1,39 @@
# Stage 1: Builder
FROM golang:1.23.2-alpine AS builder
RUN apk add --no-cache tzdata make
FROM alpine:latest AS codemirror
RUN apk add --no-cache unzip wget make
COPY Makefile .
RUN make setup-codemirror
WORKDIR /src
# Only copy go.mod and go.sum initially for better caching
COPY go.mod go.sum /src/
# Utilize build cache
FROM golang:1.22.2-alpine as builder
COPY src/ /src
COPY go.mod go.sum /src/go-proxy
WORKDIR /src/go-proxy
RUN --mount=type=cache,target="/go/pkg/mod" \
go mod download -x
go mod download
ENV GOCACHE=/root/.cache/go-build
ARG VERSION
ENV VERSION=${VERSION}
COPY scripts /src/scripts
COPY Makefile /src/
RUN --mount=type=cache,target="/go/pkg/mod" \
--mount=type=cache,target="/root/.cache/go-build" \
--mount=type=bind,src=cmd,dst=/src/cmd \
--mount=type=bind,src=internal,dst=/src/internal \
--mount=type=bind,src=pkg,dst=/src/pkg \
make build && \
mkdir -p /app/error_pages /app/certs && \
mv bin/go-proxy /app/go-proxy
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o go-proxy
# Stage 2: Final image
FROM scratch
FROM alpine:latest
LABEL maintainer="yusing@6uo.me"
LABEL proxy.exclude=1
# copy timezone data
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
RUN apk add --no-cache tzdata
RUN mkdir -p /app/templates
COPY --from=codemirror templates/codemirror/ /app/templates/codemirror
COPY templates/ /app/templates
COPY schema/ /app/schema
COPY --from=builder /src/go-proxy /app/
# copy binary
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
RUN chmod +x /app/go-proxy
ENV DOCKER_HOST unix:///var/run/docker.sock
ENV GOPROXY_DEBUG 0
EXPOSE 80
EXPOSE 8888
EXPOSE 8080
EXPOSE 443
EXPOSE 8443
WORKDIR /app
CMD ["/app/go-proxy"]
CMD ["/app/go-proxy"]

View File

@@ -1,19 +1,25 @@
VERSION ?= $(shell git describe --tags --abbrev=0)
BUILD_FLAGS ?= -s -w -X github.com/yusing/go-proxy/pkg.version=${VERSION}
export VERSION
export BUILD_FLAGS
export CGO_ENABLED = 0
export GOOS = linux
.PHONY: all build up quick-restart restart logs get udp-server
.PHONY: all setup build test up restart logs get debug run archive repush rapid-crash debug-list-containers
all: build quick-restart logs
all: debug
setup:
mkdir -p config certs
[ -f config/config.yml ] || cp config.example.yml config/config.yml
[ -f config/providers.yml ] || touch config/providers.yml
setup-codemirror:
wget https://codemirror.net/5/codemirror.zip
unzip codemirror.zip
rm codemirror.zip
mkdir -p templates
mv codemirror-* templates/codemirror
build:
scripts/build.sh
mkdir -p bin
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy src/go-proxy/*.go
test:
GOPROXY_TEST=1 go test ./internal/...
go test src/go-proxy/*.go
up:
docker compose up -d
@@ -22,25 +28,10 @@ restart:
docker compose restart -t 0
logs:
docker compose logs -f
tail -f log/go-proxy.log
get:
go get -u ./cmd && go mod tidy
debug:
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
mtrace:
bin/go-proxy debug-ls-mtrace > mtrace.json
run-test:
make build && sudo GOPROXY_TEST=1 bin/go-proxy
run:
make build && sudo bin/go-proxy
archive:
git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip
go get -d -u ./src/go-proxy
repush:
git reset --soft HEAD^
@@ -48,14 +39,11 @@ repush:
git commit -m "repush"
git push gitlab dev --force
rapid-crash:
sudo docker run --restart=always --name test_crash debian:bookworm-slim /bin/cat &&\
sleep 3 &&\
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'
ci-test:
mkdir -p /tmp/artifacts
act -n --artifact-server-path /tmp/artifacts -s GITHUB_TOKEN="$$(gh auth token)"
udp-server:
docker run -it --rm \
-p 9999:9999/udp \
--label proxy.test-udp.scheme=udp \
--label proxy.test-udp.port=20003:9999 \
--network host \
--name test-udp \
$$(docker build -q -f udp-test-server.Dockerfile .)

381
README.md
View File

@@ -1,113 +1,346 @@
# go-proxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![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)
A simple auto docker reverse proxy for home use. **Written in _Go_**
[繁體中文文檔請看此](README_CHT.md)
A lightweight, easy-to-use, and [performant](https://github.com/yusing/go-proxy/wiki/Benchmarks) reverse proxy with a Web UI and dashboard.
![Screenshot](screenshots/webui.png)
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
In the examples domain `x.y.z` is used, replace them with your domain
## Table of content
<!-- TOC -->
- [Table of content](#table-of-content)
- [Key Points](#key-points)
- [How to use](#how-to-use)
- [Tested Services](#tested-services)
- [HTTP/HTTPs Reverse Proxy](#httphttps-reverse-proxy)
- [TCP Proxy](#tcp-proxy)
- [UDP Proxy](#udp-proxy)
- [Command-line args](#command-line-args)
- [Commands](#commands)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Environment variables](#environment-variables)
- [Config File](#config-file)
- [Fields](#fields)
- [Provider Kinds](#provider-kinds)
- [Provider File](#provider-file)
- [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- [Troubleshooting](#troubleshooting)
- [Benchmarks](#benchmarks)
- [Known issues](#known-issues)
- [Memory usage](#memory-usage)
- [Build it yourself](#build-it-yourself)
<!-- /TOC -->
- [go-proxy](#go-proxy)
- [Table of content](#table-of-content)
- [Key Features](#key-features)
- [Getting Started](#getting-started)
- [Setup](#setup)
- [Use JSON Schema in VSCode](#use-json-schema-in-vscode)
- [Screenshots](#screenshots)
- [idlesleeper](#idlesleeper)
- [Build it yourself](#build-it-yourself)
## Key Points
## Key Features
- Fast (See [benchmarks](#benchmarks))
- Auto certificate obtaining and renewal (See [Config File](#config-file) and [Supported DNS Challenge Providers](#supported-dns-challenge-providers))
- Auto detect reverse proxies from docker
- Auto hot-reload on container `start` / `die` / `stop` or config file changes
- Custom proxy entries with `config.yml` and additional provider files
- Subdomain matching + Path matching **(domain name doesn't matter)**
- HTTP(s) reverse proxy + TCP/UDP Proxy
- HTTP(s) round robin load balance support (same subdomain and path across different hosts)
- Web UI on port 8080 (http) and port 8443 (https)
- Easy to use
- Effortless configuration
- Simple multi-node setup
- Error messages is clear and detailed, easy troubleshooting
- Auto SSL cert management (See [Supported DNS-01 Challenge Providers](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers))
- Auto configuration for docker containers
- Auto hot-reload on container state / config file changes
- **idlesleeper**: stop containers on idle, wake it up on traffic _(optional, see [screenshots](#idlesleeper))_
- HTTP(s) reserve proxy
- [HTTP middleware support](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [Custom error pages support](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP and UDP port forwarding
- **Web UI with App dashboard**
- Supports linux/amd64, linux/arm64
- Written in **[Go](https://go.dev)**
- a simple panel to see all reverse proxies and health
![panel screenshot](screenshots/panel.png)
- a config editor to edit config and provider files with validation
**Validate and save file with Ctrl+S**
![config editor screenshot](screenshots/config_editor.png)
[🔼Back to top](#table-of-content)
## Getting Started
## How to use
### Setup
1. Setup DNS Records to your machine's IP address
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. Start `go-proxy` by
```shell
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
```
- [Running from binary or as a system service](docs/binary.md)
- [Running as a docker container](docs/docker.md)
3. Setup DNS Records point to machine which runs `go-proxy`, e.g.
- 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 [Multi docker nodes setup](https://github.com/yusing/go-proxy/wiki/Configurations#multi-docker-nodes-setup)) and then them inside `config.yml`
5. Run go-proxy `docker compose up -d`
then list all routes to see if further configurations are needed:
`docker exec go-proxy /app/go-proxy ls-routes`
6. You may now do some extra configuration
- With text editor (e.g. Visual Studio Code)
- With Web UI via `http://localhost:3000` or `https://gp.y.z`
- For more info, [See Wiki]([wiki](https://github.com/yusing/go-proxy/wiki))
3. Start editing config files
- with text editor (i.e. Visual Studio Code)
- or with web config editor by navigate to `http://ip:8080`
[🔼Back to top](#table-of-content)
### Use JSON Schema in VSCode
## Tested Services
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
### HTTP/HTTPs Reverse Proxy
- Nginx
- Minio
- AdguardHome Dashboard
- etc.
### TCP Proxy
- Minecraft server
- PostgreSQL
- MariaDB
### UDP Proxy
- Adguardhome DNS
- Palworld Dedicated Server
[🔼Back to top](#table-of-content)
## Screenshots
## Command-line args
### idlesleeper
`go-proxy [command]`
![idlesleeper](screenshots/idlesleeper.webp)
### Commands
- empty: start proxy server
- validate: validate config and exit
- reload: trigger a force reload of config
Examples:
- Binary: `go-proxy reload`
- Docker: `docker exec -it go-proxy /app/go-proxy reload`
[🔼Back to top](#table-of-content)
## Use JSON Schema in VSCode
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify to fit your needs
```json
{
"yaml.schemas": {
"https://github.com/yusing/go-proxy/raw/main/schema/config.schema.json": [
"config.example.yml",
"config.yml"
],
"https://github.com/yusing/go-proxy/raw/main/schema/providers.schema.json": [
"providers.example.yml",
"*.providers.yml"
]
}
}
```
[🔼Back to top](#table-of-content)
## Environment variables
- `GOPROXY_DEBUG`: set to `1` or `true` to enable debug behaviors (i.e. output, etc.)
- `GOPROXY_HOST_NETWORK`: _(Docker only)_ set to `1` when `network_mode: host`
- `GOPROXY_NO_SCHEMA_VALIDATION`: disable schema validation on config load / reload **(for testing new DNS Challenge providers)**
[🔼Back to top](#table-of-content)
## Config File
See [config.example.yml](config.example.yml) for more
### Fields
- `autocert`: autocert configuration
- `email`: ACME Email
- `domains`: a list of domains for cert registration
- `provider`: DNS Challenge provider, see [Supported DNS Challenge Providers](#supported-dns-challenge-providers)
- `options`: [provider specific options](#supported-dns-challenge-providers)
- `providers`: reverse proxy providers configuration
- `kind`: provider kind (string), see [Provider Kinds](#provider-kinds)
- `value`: provider specific value
[🔼Back to top](#table-of-content)
### Provider Kinds
- `docker`: load reverse proxies from docker
values:
- `FROM_ENV`: value from environment (`DOCKER_HOST`)
- full url to docker host (i.e. `tcp://host:2375`)
- `file`: load reverse proxies from provider file
value: relative path of file to `config/`
[🔼Back to top](#table-of-content)
### Provider File
Fields are same as [docker labels](docs/docker.md#labels) starting from `scheme`
See [providers.example.yml](providers.example.yml) for examples
[🔼Back to top](#table-of-content)
### Supported DNS Challenge Providers
- Cloudflare
- `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`
- DuckDNS (thanks [earvingad](https://github.com/earvingad))
- `token`: DuckDNS Token
To add more provider support, see [this](docs/add_dns_provider.md)
[🔼Back to top](#table-of-content)
## Troubleshooting
Q: How to fix when it shows "no matching route for subdomain \<subdomain>"?
A: Make sure the container is running, and \<subdomain> matches any container name / alias
[🔼Back to top](#table-of-content)
## Benchmarks
Benchmarked with `wrk` connecting `traefik/whoami`'s `/bench` endpoint
Remote benchmark (client running wrk and `go-proxy` server are different devices)
- Direct connection
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.100.3:8003/bench
Running 10s test @ http://10.0.100.3:8003/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 94.75ms 199.92ms 1.68s 91.27%
Req/Sec 4.24k 1.79k 18.79k 72.13%
Latency Distribution
50% 1.14ms
75% 120.23ms
90% 245.63ms
99% 1.03s
423444 requests in 10.10s, 50.88MB read
Socket errors: connect 0, read 0, write 0, timeout 29
Requests/sec: 41926.32
Transfer/sec: 5.04MB
```
- With reverse proxy
```shell
root@yusing-pc:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 79.35ms 169.79ms 1.69s 92.55%
Req/Sec 4.27k 1.90k 19.61k 75.81%
Latency Distribution
50% 1.12ms
75% 105.66ms
90% 200.22ms
99% 814.59ms
409836 requests in 10.10s, 49.25MB read
Socket errors: connect 0, read 0, write 0, timeout 18
Requests/sec: 40581.61
Transfer/sec: 4.88MB
```
Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs)
- Direct connection
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://10.0.100.1/bench
Running 10s test @ http://10.0.100.1/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 434.08us 539.35us 8.76ms 85.28%
Req/Sec 67.71k 6.31k 87.21k 71.20%
Latency Distribution
50% 153.00us
75% 646.00us
90% 1.18ms
99% 2.38ms
6739591 requests in 10.01s, 809.85MB read
Requests/sec: 673608.15
Transfer/sec: 80.94MB
```
- With `go-proxy` reverse proxy
```shell
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s -H "Host: bench.6uo.me" --latency http://10.0.1.7/bench
Running 10s test @ http://10.0.1.7/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.23ms 0.96ms 11.43ms 72.09%
Req/Sec 17.48k 1.76k 21.48k 70.20%
Latency Distribution
50% 0.98ms
75% 1.76ms
90% 2.54ms
99% 4.24ms
1739079 requests in 10.01s, 208.97MB read
Requests/sec: 173779.44
Transfer/sec: 20.88MB
```
- With `traefik-v3`
```shell
root@traefik-benchmark:~# wrk -t10 -c200 -d10s -H "Host: benchmark.whoami" --latency http://127.0.0.1:8000/bench
Running 10s test @ http://127.0.0.1:8000/bench
10 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 2.81ms 10.36ms 180.26ms 98.57%
Req/Sec 11.35k 1.74k 13.76k 85.54%
Latency Distribution
50% 1.59ms
75% 2.27ms
90% 3.17ms
99% 37.91ms
1125723 requests in 10.01s, 109.50MB read
Requests/sec: 112499.59
Transfer/sec: 10.94MB
```
[🔼Back to top](#table-of-content)
## Known issues
- Cert "renewal" is actually obtaining a new cert instead of renewing the existing one
[🔼Back to top](#table-of-content)
## Memory usage
It takes ~15 MB for 50 proxy entries
[🔼Back to top](#table-of-content)
## Build it yourself
1. Clone the repository `git clone https://github.com/yusing/go-proxy --depth=1`
1. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
2. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
3. get dependencies with `make get`
4. get dependencies with `make get`
4. build binary with `make build`
5. build binary with `make build`
5. start your container with `make up` (docker) or `bin/go-proxy` (binary)
[🔼Back to top](#table-of-content)

View File

@@ -1,130 +0,0 @@
# go-proxy
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=yusing_go-proxy&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
[![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](https://github.com/yusing/go-proxy/wiki/Benchmarks)))的反向代理和端口轉發工具
## 目錄
<!-- TOC -->
- [go-proxy](#go-proxy)
- [目錄](#目錄)
- [重點](#重點)
- [入門指南](#入門指南)
- [安裝](#安裝)
- [命令行參數](#命令行參數)
- [環境變量](#環境變量)
- [VSCode 中使用 JSON Schema](#vscode-中使用-json-schema)
- [展示](#展示)
- [idlesleeper](#idlesleeper)
- [源碼編譯](#源碼編譯)
## 重點
- 易用
- 不需花費太多時間就能輕鬆配置
- 支持多個docker節點
- 除錯簡單
- 自動配置 SSL 證書(參見[可用的 DNS 供應商](https://github.com/yusing/go-proxy/wiki/Supported-DNS%E2%80%9001-Providers)
- 透過 Docker 容器自動配置
- 容器狀態變更時自動熱重載
- **idlesleeper** 容器閒置時自動暫停/停止,入站時自動喚醒 (可選, 參見 [展示](#idlesleeper))
- HTTP(s) 反向代理
- [HTTP middleware](https://github.com/yusing/go-proxy/wiki/Middlewares)
- [自訂 error pages](https://github.com/yusing/go-proxy/wiki/Middlewares#custom-error-pages)
- TCP/UDP 端口轉發
- Web 面板 (內置App dashboard)
- 支持 linux/amd64、linux/arm64 平台
- 使用 **[Go](https://go.dev)** 編寫
[🔼 返回頂部](#目錄)
## 入門指南
### 安裝
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`
4. 配置 `docker-socket-proxy` 其他 Docker 節點(如有) (參見 [範例](docs/docker_socket_proxy.md)) 然後加到 `config.yml` 中
5. 大功告成,你可以做一些額外的配置
- 使用文本編輯器 (推薦 Visual Studio Code [參見 VSCode 使用 schema](#vscode-中使用-json-schema))
- 或通過 `http://localhost:3000` 使用網頁配置編輯器
- 詳情請參閱 [docker.md](docs/docker.md)
[🔼 返回頂部](#目錄)
### 命令行參數
| 參數 | 描述 | 示例 |
| ------------------------- | ------------------------------------------------------------------------------------- | ----------------------------------- |
| 空 | 啟動代理服務器 | |
| `validate` | 驗證配置並退出 | |
| `reload` | 強制刷新配置 | |
| `ls-config` | 列出配置並退出 | `go-proxy ls-config \| jq` |
| `ls-route` | 列出路由並退出 | `go-proxy ls-route \| jq` |
| `go-proxy ls-route \| jq` |
| `ls-icons` | 列出 [dashboard-icons](https://github.com/walkxcode/dashboard-icons/tree/main) 並退出 | `go-proxy ls-icons \| grep adguard` |
| `debug-ls-mtrace` | 列出middleware追蹤 **(僅限於 debug 模式)** | `go-proxy debug-ls-mtrace \| jq` |
**使用 `docker exec go-proxy /app/go-proxy <參數>` 運行**
### 環境變量
| 環境變量 | 描述 | 默認 | 格式 |
| ------------------------------ | ---------------- | ---------------- | ------------- |
| `GOPROXY_NO_SCHEMA_VALIDATION` | 禁用 schema 驗證 | `false` | boolean |
| `GOPROXY_DEBUG` | 啟用調試輸出 | `false` | boolean |
| `GOPROXY_HTTP_ADDR` | http 收聽地址 | `:80` | `[host]:port` |
| `GOPROXY_HTTPS_ADDR` | https 收聽地址 | `:443` | `[host]:port` |
| `GOPROXY_API_ADDR` | api 收聽地址 | `127.0.0.1:8888` | `[host]:port` |
### VSCode 中使用 JSON Schema
複製 [`.vscode/settings.example.json`](.vscode/settings.example.json) 到 `.vscode/settings.json` 並根據需求修改
[🔼 返回頂部](#目錄)
## 展示
### idlesleeper
![idlesleeper](screenshots/idlesleeper.webp)
[🔼 返回頂部](#目錄)
## 源碼編譯
1. 獲取源碼 `git clone https://github.com/yusing/go-proxy --depth=1`
2. 安裝/升級 [go 版本 (>=1.22)](https://go.dev/doc/install) 和 `make`(如果尚未安裝)
3. 如果之前編譯過go 版本 < 1.22),請使用 `go clean -cache` 清除緩存
4. 使用 `make get` 獲取依賴項
5. 使用 `make build` 編譯
[🔼 返回頂部](#目錄)

View File

@@ -1,223 +0,0 @@
package main
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"os/signal"
"reflect"
"runtime"
"strings"
"syscall"
"time"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal"
"github.com/yusing/go-proxy/internal/api"
"github.com/yusing/go-proxy/internal/api/v1/query"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/docker"
"github.com/yusing/go-proxy/internal/docker/idlewatcher"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/net/http/middleware"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/server"
F "github.com/yusing/go-proxy/internal/utils/functional"
"github.com/yusing/go-proxy/pkg"
)
func main() {
args := common.GetArgs()
if args.Command == common.CommandSetup {
internal.Setup()
return
}
l := logrus.WithField("module", "main")
onShutdown := F.NewSlice[func()]()
if common.IsDebug {
logrus.SetLevel(logrus.DebugLevel)
}
if args.Command != common.CommandStart {
logrus.SetOutput(io.Discard)
} else {
logrus.SetFormatter(&logrus.TextFormatter{
DisableSorting: true,
FullTimestamp: true,
ForceColors: true,
TimestampFormat: "01-02 15:04:05",
})
logrus.Infof("go-proxy version %s", pkg.GetVersion())
}
if args.Command == common.CommandReload {
if err := query.ReloadServer(); err != nil {
log.Fatal(err)
}
log.Print("ok")
return
}
// exit if only validate config
if args.Command == common.CommandValidate {
data, err := os.ReadFile(common.ConfigPath)
if err == nil {
err = config.Validate(data).Error()
}
if err != nil {
log.Fatal("config error: ", err)
}
log.Print("config OK")
return
}
for _, dir := range common.RequiredDirectories {
prepareDirectory(dir)
}
middleware.LoadComposeFiles()
if err := config.Load(); err != nil {
logrus.Warn(err)
}
cfg := config.GetInstance()
switch args.Command {
case common.CommandListConfigs:
printJSON(cfg.Value())
return
case common.CommandListRoutes:
routes, err := query.ListRoutes()
if err != nil {
log.Printf("failed to connect to api server: %s", err)
log.Printf("falling back to config file")
printJSON(cfg.RoutesByAlias())
} else {
printJSON(routes)
}
return
case common.CommandListIcons:
icons, err := internal.ListAvailableIcons()
if err != nil {
log.Fatal(err)
}
printJSON(icons)
return
case common.CommandDebugListEntries:
printJSON(cfg.DumpEntries())
return
case common.CommandDebugListProviders:
printJSON(cfg.DumpProviders())
return
case common.CommandDebugListMTrace:
trace, err := query.ListMiddlewareTraces()
if err != nil {
log.Fatal(err)
}
printJSON(trace)
}
cfg.StartProxyProviders()
cfg.WatchChanges()
onShutdown.Add(docker.CloseAllClients)
onShutdown.Add(cfg.Dispose)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT)
signal.Notify(sig, syscall.SIGTERM)
signal.Notify(sig, syscall.SIGHUP)
autocert := cfg.GetAutoCertProvider()
if autocert != nil {
ctx, cancel := context.WithCancel(context.Background())
onShutdown.Add(cancel)
if err := autocert.Setup(ctx); err != nil {
l.Fatal(err)
}
} else {
l.Info("autocert not configured")
}
proxyServer := server.InitProxyServer(server.Options{
Name: "proxy",
CertProvider: autocert,
HTTPAddr: common.ProxyHTTPAddr,
HTTPSAddr: common.ProxyHTTPSAddr,
Handler: http.HandlerFunc(R.ProxyHandler),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
apiServer := server.InitAPIServer(server.Options{
Name: "api",
CertProvider: autocert,
HTTPAddr: common.APIHTTPAddr,
Handler: api.NewHandler(cfg),
RedirectToHTTPS: cfg.Value().RedirectToHTTPS,
})
proxyServer.Start()
apiServer.Start()
onShutdown.Add(proxyServer.Stop)
onShutdown.Add(apiServer.Stop)
go idlewatcher.Start()
onShutdown.Add(idlewatcher.Stop)
// wait for signal
<-sig
// grafully shutdown
logrus.Info("shutting down")
done := make(chan struct{}, 1)
currentIdx := 0
go func() {
onShutdown.ForEach(func(f func()) {
l.Debugf("waiting for %s to complete...", funcName(f))
f()
currentIdx++
l.Debugf("%s done", funcName(f))
})
close(done)
}()
timeout := time.After(time.Duration(cfg.Value().TimeoutShutdown) * time.Second)
select {
case <-done:
logrus.Info("shutdown complete")
case <-timeout:
logrus.Info("timeout waiting for shutdown")
for i := currentIdx; i < onShutdown.Size(); i++ {
l.Warnf("%s() is still running", funcName(onShutdown.Get(i)))
}
}
}
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.MarshalIndent(obj, "", " "))
if err != nil {
logrus.Fatal(err)
}
rawLogger := log.New(os.Stdout, "", 0)
rawLogger.Printf("%s", j) // raw output for convenience using "jq"
}

View File

@@ -1,45 +1,45 @@
version: '3'
services:
frontend:
image: ghcr.io/yusing/go-proxy-frontend:latest
container_name: go-proxy-frontend
restart: unless-stopped
network_mode: host
depends_on:
- app
# if you also want to proxy the WebUI and access it via gp.y.z
# labels:
# - proxy.aliases=gp
# - proxy.gp.port=3000
app:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
networks: # ^also add here
- default
ports:
- 80:80 # http proxy
- 8080:8080 # http panel
# - 443:443 # optional, https proxy
# - 8443:8443 # optional, https panel
# Make sure the value is same as `GOPROXY_API_ADDR` below (if you have changed it)
#
# environment:
# GOPROXY_API_ADDR: 127.0.0.1:8888
app:
image: ghcr.io/yusing/go-proxy:latest
container_name: go-proxy
restart: always
network_mode: host
environment:
# (Optional) change this to your timezone to get correct log timestamp
TZ: ETC/UTC
# optional, if you declared any tcp/udp proxy, set a range you want to use
# - 20000:20100/tcp
# - 20000:20100/udp
volumes:
- ./config:/app/config
# Change these if you need
#
# GOPROXY_HTTP_ADDR: :80
# GOPROXY_HTTPS_ADDR: :443
# GOPROXY_API_ADDR: 127.0.0.1:8888
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./config:/app/config
# if local docker provider is used
- /var/run/docker.sock:/var/run/docker.sock:ro
# use existing certificate
# - /path/to/cert.pem:/app/certs/cert.crt:ro
# - /path/to/privkey.pem:/app/certs/priv.key:ro
# (Optional) choose one of below to enable https
# 1. use existing certificate
# if your cert is not named `cert.crt` change `cert_path` in `config/config.yml`
# if your cert key is not named `priv.key` change `key_path` in `config/config.yml`
# store autocert obtained cert
# - ./certs:/app/certs
# workaround for "lookup: no such host"
# dns:
# - 127.0.0.1
# - /path/to/certs:/app/certs
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
# - ./certs:/app/certs
# if you have container running in "host" network mode
# extra_hosts:
# - host.docker.internal:host-gateway
logging:
driver: 'json-file'
options:
max-file: '1'
max-size: 128k
networks: # ^you may add other external networks
default:
driver: bridge

View File

@@ -1,69 +1,21 @@
# 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
#
# 2. cloudflare
#
# autocert:
# provider: cloudflare
# 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 docs/dns_providers.md for more
# Autocert (uncomment to enable)
# autocert: # (optional, if you need autocert feature)
# email: "user@domain.com" # (required) email for acme certificate
# domains: # (required)
# - "*.y.z" # domain for acme certificate, use wild card to allow all subdomains
# provider: cloudflare # (required) dns challenge provider (string)
# options: # provider specific options
# auth_token: "YOUR_ZONE_API_TOKEN"
providers:
# include files are standalone yaml files under `config/` directory
#
# include:
# - file1.yml
# - file2.yml
docker:
# $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
local:
kind: docker
# 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
# i.e. FROM_ENV, ssh://user@10.0.1.1:22, tcp://10.0.2.1:2375
value: FROM_ENV
providers:
kind: file
value: providers.yml
# Below are fixed options (non hot-reloadable)
# timeout for shutdown (in seconds)
#
# Fixed options (optional, non hot-reloadable)
# 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
# redirect_to_https: false

41
docs/add_dns_provider.md Normal file
View File

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

59
docs/binary.md Normal file
View File

@@ -0,0 +1,59 @@
# Getting started with `go-proxy` (binary)
## Setup
1. Install `bash`, `make` and `wget` if not already
2. Run setup script
To specitfy a version _(optional)_
```shell
export VERSION=latest # will be resolved into real version number
export VERSION=<version>
```
If you don't need web config editor
```shell
export SETUP_CODEMIRROR=0
```
Setup
```shell
wget -qO- https://6uo.me/go-proxy-setup-binary | sudo bash
```
What it does:
- Download source file and binary into /opt/go-proxy/$VERSION
- Setup `config.yml` and `providers.yml`
- Setup `template/codemirror` which is a dependency for web config editor
- Create a systemd service (if available) in `/etc/systemd/system/go-proxy.service`
- Enable and start `go-proxy` service
3. Start editing config files in `http://<ip>:8080`
4. Check logs / status with `systemctl status go-proxy`
## Setup (alternative method)
1. Download the latest release and extract somewhere
2. Run `make setup` and _(optional) `make setup-codemirror`_
3. Enable HTTPS _(optional)_
- To use autocert feature
complete `autocert` in `config/config.yml`
- To use existing certificate
Prepare your wildcard (`*.y.z`) SSL cert in `certs/`
- cert / chain / fullchain: `certs/cert.crt`
- private key: `certs/priv.key`
4. Run the binary `bin/go-proxy`

365
docs/docker.md Normal file
View File

@@ -0,0 +1,365 @@
# Docker container guide
## Table of content
<!-- TOC -->
- [Table of content](#table-of-content)
- [Setup](#setup)
- [Labels](#labels)
- [Labels (docker specific)](#labels-docker-specific)
- [Troubleshooting](#troubleshooting)
- [Docker compose examples](#docker-compose-examples)
- [Local docker provider in bridge network](#local-docker-provider-in-bridge-network)
- [Remote docker provider](#remote-docker-provider)
- [Explaination](#explaination)
- [Remote setup](#remote-setup)
- [Proxy setup](#proxy-setup)
- [Local docker provider in host network](#local-docker-provider-in-host-network)
- [Proxy setup](#proxy-setup)
- [Services URLs for above examples](#services-urls-for-above-examples)
<!-- /TOC -->
## Setup
1. Install `wget` if not already
2. Run setup script
`bash <(wget -qO- https://6uo.me/go-proxy-setup-docker)`
What it does:
- Create required directories
- Setup `config.yml` and `compose.yml`
3. Verify folder structure and then `cd go-proxy`
```plain
go-proxy
├── certs
├── compose.yml
└── config
├── config.yml
└── providers.yml
```
4. Enable HTTPs _(optional)_
- To use autocert feature
- completing `autocert` section in `config/config.yml`
- mount `certs/` to `/app/certs` to store obtained certs
- To use existing certificate
mount your wildcard (`*.y.z`) SSL cert
- cert / chain / fullchain -> `/app/certs/cert.crt`
- private key -> `/app/certs/priv.key`
5. Modify `compose.yml` fit your needs
Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
6. Run `docker compose up -d` to start the container
7. Start editing config files in `http://<ip>:8080`
[🔼Back to top](#table-of-content)
## Labels
- `proxy.aliases`: comma separated aliases for subdomain matching
- default: container name
- `proxy.*.<field>`: wildcard label for all aliases
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.nginx.scheme: http`)
- `scheme`: proxy protocol
- default: `http`
- allowed: `http`, `https`, `tcp`, `udp`
- `host`: proxy host
- default: `container_name`
- `port`: proxy port
- default: first expose port (declared in `Dockerfile` or `docker-compose.yml`)
- `http(s)`: number in range og `0 - 65535`
- `tcp/udp`: `[<listeningPort>:]<targetPort>`
- `listeningPort`: number, when it is omitted (not suggested), a free port starting from 20000 will be used.
- `targetPort`: number, or predefined names (see [constants.go:14](src/go-proxy/constants.go#L14))
- `no_tls_verify`: whether skip tls verify when scheme is https
- default: `false`
- `path`: proxy path _(http(s) proxy only)_
- default: empty
- `path_mode`: mode for path handling
- default: empty
- allowed: empty, `forward`, `sub`
- `empty`: remove path prefix from URL when proxying
1. apps.y.z/webdav -> webdav:80
2. apps.y.z./webdav/path/to/file -> webdav:80/path/to/file
- `forward`: path remain unchanged
1. apps.y.z/webdav -> webdav:80/webdav
2. apps.y.z./webdav/path/to/file -> webdav:80/webdav/path/to/file
- `sub`: **(experimental)** remove path prefix from URL and also append path to HTML link attributes (`src`, `href` and `action`) and Javascript `fetch(url)` by response body substitution
e.g. apps.y.z/app1 -> webdav:80, `href="/app1/path/to/file"` -> `href="/path/to/file"`
- `set_headers`: a list of header to set, (key:value, one by line)
Duplicated keys will be treated as multiple-value headers
```yaml
labels:
proxy.app.set_headers: |
X-Custom-Header1: value1
X-Custom-Header1: value2
X-Custom-Header2: value2
```
- `hide_headers`: comma seperated list of headers to hide
[🔼Back to top](#table-of-content)
## Labels (docker specific)
Below labels has a **`proxy.<alias>.`** prefix (i.e. `proxy.app.load_balance=1`)
- `load_balance`: enable load balance
- allowed: `1`, `true`
[🔼Back to top](#table-of-content)
## Troubleshooting
- Firewall issues
If you are using `ufw` with vpn that drop all inbound traffic except vpn, run below:
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
Explaination:
Docker network is usually `172.16.0.0/16`
Tailscale is used as an example, `100.64.0.0/10` will be the CIDR
You can also list CIDRs of all docker bridge networks by:
`docker network inspect $(docker network ls | awk '$3 == "bridge" { print $1}') | jq -r '.[] | .Name + " " + .IPAM.Config[0].Subnet' -`
[🔼Back to top](#table-of-content)
## Docker compose examples
### Local docker provider in bridge network
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
labels:
- proxy.aliases=adg,adg-dns,adg-setup
- proxy.adg.port=80
- proxy.adg-setup.port=3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=20000:dns
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=20001:25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=20002:8211
- proxy.pal2.port=20003:27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
volumes:
- nginx:/usr/share/nginx/html
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
ports:
- 80:80 # http
- 443:443 # optional, https
- 8080:8080 # http panel
- 8443:8443 # optional, https panel
- 53:20000/udp # adguardhome
- 25565:20001/tcp # minecraft
- 8211:20002/udp # palworld
- 27015:20003/udp # palworld
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Remote docker provider
#### Explaination
- Expose container ports to random port in remote host
- Use container port with an asterisk sign **(\*)** before to find remote port automatically
#### Remote setup
```yaml
volumes:
adg-work:
adg-conf:
mc-data:
palworld:
nginx:
services:
adg:
image: adguard/adguardhome
restart: unless-stopped
ports: # map container ports
- 80
- 3000
- 53/udp
- 53/tcp
labels:
- proxy.aliases=adg,adg-dns,adg-setup
# add an asterisk (*) before to find host port automatically
- proxy.adg.port=*80
- proxy.adg-setup.port=*3000
- proxy.adg-dns.scheme=udp
- proxy.adg-dns.port=*53
volumes:
- adg-work:/opt/adguardhome/work
- adg-conf:/opt/adguardhome/conf
mc:
image: itzg/minecraft-server
tty: true
stdin_open: true
container_name: mc
restart: unless-stopped
ports:
- 25565
labels:
- proxy.mc.scheme=tcp
- proxy.mc.port=*25565
environment:
- EULA=TRUE
volumes:
- mc-data:/data
palworld:
image: thijsvanloef/palworld-server-docker:latest
restart: unless-stopped
container_name: pal
stop_grace_period: 30s
ports:
- 8211/udp
- 27015/udp
labels:
- proxy.aliases=pal1,pal2
- proxy.*.scheme=udp
- proxy.pal1.port=*8211
- proxy.pal2.port=*27015
environment: ...
volumes:
- palworld:/palworld
nginx:
image: nginx
container_name: nginx
# for single port container, host port will be found automatically
ports:
- 80
volumes:
- nginx:/usr/share/nginx/html
```
[🔼Back to top](#table-of-content)
#### Proxy setup
```yaml
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
network_mode: host
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Local docker provider in host network
Mostly as remote docker setup, see [remote setup](#remote-setup)
With `GOPROXY_HOST_NETWORK=1` to treat it as remote docker provider
#### Proxy setup
```yaml
go-proxy:
image: ghcr.io/yusing/go-proxy
container_name: go-proxy
restart: always
network_mode: host
environment: # this part is needed for local docker in host mode
- GOPROXY_HOST_NETWORK=1
volumes:
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock:ro
labels:
- proxy.aliases=gp
- proxy.gp.port=8080
```
[🔼Back to top](#table-of-content)
### Services URLs for above examples
- `gp.yourdomain.com`: go-proxy web panel
- `adg-setup.yourdomain.com`: adguard setup (first time setup)
- `adg.yourdomain.com`: adguard dashboard
- `nginx.yourdomain.com`: nginx
- `yourdomain.com:53`: adguard dns
- `yourdomain.com:25565`: minecraft server
- `yourdomain.com:8211`: palworld server
[🔼Back to top](#table-of-content)

View File

@@ -1,16 +0,0 @@
services:
app:
container_name: microbin
cpu_shares: 10
deploy:
resources:
limits:
memory: 256M
env_file: .env
image: danielszabo99/microbin:latest
ports:
- 8080
restart: unless-stopped
volumes:
- ./data:/app/microbin_data
# microbin.domain.tld

View File

@@ -1,16 +0,0 @@
services:
main:
image: b3log/siyuan:v3.1.0
container_name: siyuan
command:
- --workspace=/siyuan/workspace/
- --accessAuthCode=<some password>
user: 1000:1000
volumes:
- ./workspace:/siyuan/workspace
restart: unless-stopped
environment:
- TZ=Asia/Hong_Kong
ports:
- 6806
# siyuan.domain.tld

51
go.mod Normal file → Executable file
View File

@@ -1,59 +1,54 @@
module github.com/yusing/go-proxy
go 1.23.2
go 1.22
require (
github.com/coder/websocket v1.8.12
github.com/docker/cli v27.3.1+incompatible
github.com/docker/docker v27.3.1+incompatible
github.com/docker/cli v26.1.3+incompatible
github.com/docker/docker v26.1.3+incompatible
github.com/fsnotify/fsnotify v1.7.0
github.com/go-acme/lego/v4 v4.19.2
github.com/puzpuzpuz/xsync/v3 v3.4.0
github.com/go-acme/lego/v4 v4.17.3
github.com/santhosh-tekuri/jsonschema v1.2.4
github.com/sirupsen/logrus v1.9.3
golang.org/x/net v0.30.0
golang.org/x/text v0.19.0
golang.org/x/net v0.25.0
gopkg.in/yaml.v3 v3.0.1
)
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.107.0 // indirect
github.com/cloudflare/cloudflare-go v0.96.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
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
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/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.6 // indirect
github.com/miekg/dns v1.1.59 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
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.30.0 // indirect
go.opentelemetry.io/otel/metric v1.30.0 // indirect
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
go.opentelemetry.io/otel/trace v1.30.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.org/x/tools v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect
go.opentelemetry.io/otel v1.27.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.27.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.27.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.21.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

155
go.sum Normal file → Executable file
View File

@@ -4,35 +4,33 @@ 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.107.0 h1:cMDIw2tzt6TXCJyMFVyP+BPOVkIfMvcKjhMNSNvuEPc=
github.com/cloudflare/cloudflare-go v0.107.0/go.mod h1:5cYGzVBqNTLxMYSLdVjuSs5LJL517wJDSvMPWUrzHzc=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cloudflare/cloudflare-go v0.96.0 h1:wd+qrnyw+C2eXUUujE6BzFEOREkEfoCvogpO5h33FxI=
github.com/cloudflare/cloudflare-go v0.96.0/go.mod h1:gLP9fJT8ROgRCjHNKxISNNKeU1JEg2yT5uPEEI8x9Ec=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/cli v26.1.3+incompatible h1:bUpXT/N0kDE3VUHI2r5VMsYQgi38kYuoC0oL9yt3lqc=
github.com/docker/cli v26.1.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v26.1.3+incompatible h1:lLCzRbrVZrljpVNobJu1J2FHk8V0s4BawoZippkc+xo=
github.com/docker/docker v26.1.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-acme/lego/v4 v4.19.2 h1:Y8hrmMvWETdqzzkRly7m98xtPJJivWFsgWi8fcvZo+Y=
github.com/go-acme/lego/v4 v4.19.2/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ=
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/go-acme/lego/v4 v4.17.3 h1:5our7Qdyik0abag40abdmQuytq97iweaNHFMT4pYDnQ=
github.com/go-acme/lego/v4 v4.17.3/go.mod h1:Ol6l04hnmavqVHKYS/ByhXXqE64x8yVYhomha82uAUk=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -47,22 +45,26 @@ 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/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/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/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.6 h1:TwRYfx2z2C4cLbXmT8I5PgP/xmuqASDyiVuGYfs9GZM=
github.com/hashicorp/go-retryablehttp v0.7.6/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
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.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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=
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -73,19 +75,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/puzpuzpuz/xsync/v3 v3.4.0 h1:DuVBAdXuGFHv8adVXjWWZ63pJq+NRXOWVXlKDBZ+mJ4=
github.com/puzpuzpuz/xsync/v3 v3.4.0/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
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/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
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=
@@ -96,80 +91,76 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
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.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.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.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0=
go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
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/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
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/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
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=
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=
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=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,62 +0,0 @@
package api
import (
"fmt"
"net/http"
v1 "github.com/yusing/go-proxy/internal/api/v1"
"github.com/yusing/go-proxy/internal/api/v1/errorpage"
. "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/version", v1.GetVersion)
mux.HandleFunc("GET", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
mux.HandleFunc("HEAD", "/v1/checkhealth", wrap(cfg, v1.CheckHealth))
mux.HandleFunc("POST", "/v1/reload", wrap(cfg, v1.Reload))
mux.HandleFunc("GET", "/v1/list", wrap(cfg, v1.List))
mux.HandleFunc("GET", "/v1/list/{what}", wrap(cfg, v1.List))
mux.HandleFunc("GET", "/v1/file", v1.GetFileContent)
mux.HandleFunc("GET", "/v1/file/{filename...}", v1.GetFileContent)
mux.HandleFunc("POST", "/v1/file/{filename...}", v1.SetFileContent)
mux.HandleFunc("PUT", "/v1/file/{filename...}", v1.SetFileContent)
mux.HandleFunc("GET", "/v1/stats", wrap(cfg, v1.Stats))
mux.HandleFunc("GET", "/v1/stats/ws", wrap(cfg, v1.StatsWS))
mux.HandleFunc("GET", "/v1/error_page", errorpage.GetHandleFunc())
return mux
}
// allow only requests to API server with host matching common.APIHTTPAddr.
func checkHost(f http.HandlerFunc) http.HandlerFunc {
if common.IsDebug {
return f
}
return func(w http.ResponseWriter, r *http.Request) {
if r.Host != common.APIHTTPAddr {
Logger.Warnf("invalid request to API server with host: %s, expect %s", r.Host, common.APIHTTPAddr)
http.Error(w, "invalid request", http.StatusForbidden)
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

@@ -1,42 +0,0 @@
package v1
import (
"fmt"
"net/http"
"strings"
. "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) {
target := r.FormValue("target")
if target == "" {
HandleErr(w, r, ErrMissingKey("target"), http.StatusBadRequest)
return
}
var ok bool
route := cfg.FindRoute(target)
switch {
case route == nil:
HandleErr(w, r, ErrNotFound("target", target), http.StatusNotFound)
return
case route.Type() == R.RouteTypeReverseProxy:
ok = IsSiteHealthy(route.URL().String())
case route.Type() == R.RouteTypeStream:
entry := route.Entry()
ok = IsStreamHealthy(
strings.Split(entry.Scheme, ":")[1], // target scheme
fmt.Sprintf("%s:%v", entry.Host, strings.Split(entry.Port, ":")[1]),
)
}
if ok {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusRequestTimeout)
}
}

View File

@@ -1,90 +0,0 @@
package errorpage
import (
"context"
"fmt"
"os"
"path"
"sync"
. "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 (
dirWatcher W.Watcher
fileContentMap = F.NewMapOf[string, []byte]()
)
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 {
Logger.Error(err)
return
}
for _, file := range files {
if fileContentMap.Has(file) {
continue
}
content, err := os.ReadFile(file)
if err != nil {
Logger.Errorf("failed to read error page resource %s: %s", file, err)
continue
}
file = path.Base(file)
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)
Logger.Infof("error page resource %s deleted", filename)
case events.ActionFileRenamed:
Logger.Infof("error page resource %s deleted", filename)
fileContentMap.Delete(filename)
loadContent()
}
case err := <-errCh:
Logger.Errorf("error watching error page directory: %s", err)
}
}
}

View File

@@ -1,31 +0,0 @@
package errorpage
import (
"net/http"
. "github.com/yusing/go-proxy/internal/api/v1/utils"
)
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
}
if _, err := w.Write(content); err != nil {
HandleErr(w, r, err)
}
}

View File

@@ -1,60 +0,0 @@
package v1
import (
"io"
"net/http"
"os"
"path"
"strings"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/proxy/provider"
)
func GetFileContent(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if filename == "" {
filename = common.ConfigFileName
}
content, err := os.ReadFile(path.Join(common.ConfigBasePath, filename))
if err != nil {
U.HandleErr(w, r, err)
return
}
U.WriteBody(w, content)
}
func SetFileContent(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if filename == "" {
U.HandleErr(w, r, U.ErrMissingKey("filename"), http.StatusBadRequest)
return
}
content, err := io.ReadAll(r.Body)
if err != nil {
U.HandleErr(w, r, err)
return
}
var validateErr E.NestedError
if filename == common.ConfigFileName {
validateErr = config.Validate(content)
} else if !strings.HasPrefix(filename, path.Base(common.MiddlewareComposeBasePath)) {
validateErr = provider.Validate(content)
}
if validateErr != nil {
U.RespondJSON(w, r, validateErr.JSONObject(), http.StatusBadRequest)
return
}
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0o644)
if err != nil {
U.HandleErr(w, r, err)
return
}
w.WriteHeader(http.StatusOK)
}

View File

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

@@ -1,11 +0,0 @@
package v1
import (
"net/http"
. "github.com/yusing/go-proxy/internal/api/v1/utils"
)
func Index(w http.ResponseWriter, r *http.Request) {
WriteBody(w, []byte("API ready"))
}

View File

@@ -1,87 +0,0 @@
package v1
import (
"net/http"
"strings"
U "github.com/yusing/go-proxy/internal/api/v1/utils"
"github.com/yusing/go-proxy/internal/common"
"github.com/yusing/go-proxy/internal/config"
"github.com/yusing/go-proxy/internal/net/http/middleware"
"github.com/yusing/go-proxy/internal/utils"
)
const (
ListRoutes = "routes"
ListConfigFiles = "config_files"
ListMiddlewares = "middlewares"
ListMiddlewareTrace = "middleware_trace"
ListMatchDomains = "match_domains"
ListHomepageConfig = "homepage_config"
)
func List(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
what := r.PathValue("what")
if what == "" {
what = ListRoutes
}
switch what {
case ListRoutes:
listRoutes(cfg, w, r)
case ListConfigFiles:
listConfigFiles(w, r)
case ListMiddlewares:
listMiddlewares(w, r)
case ListMiddlewareTrace:
listMiddlewareTrace(w, r)
case ListMatchDomains:
listMatchDomains(cfg, w, r)
case ListHomepageConfig:
listHomepageConfig(cfg, w, r)
default:
U.HandleErr(w, r, U.ErrInvalidKey("what"), http.StatusBadRequest)
}
}
func listRoutes(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
routes := cfg.RoutesByAlias()
typeFilter := r.FormValue("type")
if typeFilter != "" {
for k, v := range routes {
if v["type"] != typeFilter {
delete(routes, k)
}
}
}
U.RespondJSON(w, r, routes)
}
func listConfigFiles(w http.ResponseWriter, r *http.Request) {
files, err := utils.ListFiles(common.ConfigBasePath, 1)
if err != nil {
U.HandleErr(w, r, err)
return
}
for i := range files {
files[i] = strings.TrimPrefix(files[i], common.ConfigBasePath+"/")
}
U.RespondJSON(w, r, files)
}
func listMiddlewareTrace(w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, middleware.GetAllTrace())
}
func listMiddlewares(w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, middleware.All())
}
func listMatchDomains(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, cfg.Value().MatchDomains)
}
func listHomepageConfig(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
U.RespondJSON(w, r, cfg.HomepageConfig())
}

View File

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

View File

@@ -1,16 +0,0 @@
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, r, err.JSONObject(), http.StatusInternalServerError)
} else {
w.WriteHeader(http.StatusOK)
}
}

View File

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

View File

@@ -1,37 +0,0 @@
package utils
import (
"errors"
"fmt"
"net/http"
"github.com/sirupsen/logrus"
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) {
if origErr == nil {
return
}
err := E.From(origErr).Subjectf("%s %s", r.Method, r.URL)
Logger.Error(err)
if len(code) > 0 {
http.Error(w, err.String(), code[0])
return
}
http.Error(w, err.String(), http.StatusInternalServerError)
}
func ErrMissingKey(k string) error {
return errors.New("missing key '" + k + "' in query or request body")
}
func ErrInvalidKey(k string) error {
return errors.New("invalid key '" + k + "' in query or request body")
}
func ErrNotFound(k, v string) error {
return fmt.Errorf("key %q with value %q not found", k, v)
}

View File

@@ -1,29 +0,0 @@
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},
},
}
Get = HTTPClient.Get
Post = HTTPClient.Post
Head = HTTPClient.Head
)

View File

@@ -1,30 +0,0 @@
package utils
import (
"encoding/json"
"net/http"
)
func WriteBody(w http.ResponseWriter, body []byte) {
if _, err := w.Write(body); err != nil {
HandleErr(w, nil, err)
}
}
func RespondJSON(w http.ResponseWriter, r *http.Request, data any, code ...int) bool {
if len(code) > 0 {
w.WriteHeader(code[0])
}
w.Header().Set("Content-Type", "application/json")
j, err := json.MarshalIndent(data, "", " ")
if err != nil {
HandleErr(w, r, err)
return false
}
_, err = w.Write(j)
if err != nil {
HandleErr(w, r, err)
return false
}
return true
}

View File

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

View File

@@ -1,76 +0,0 @@
package autocert
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
E "github.com/yusing/go-proxy/internal/error"
"github.com/yusing/go-proxy/internal/types"
)
type Config types.AutoCertConfig
func NewConfig(cfg *types.AutoCertConfig) *Config {
if cfg.CertPath == "" {
cfg.CertPath = CertFileDefault
}
if cfg.KeyPath == "" {
cfg.KeyPath = KeyFileDefault
}
if cfg.Provider == "" {
cfg.Provider = ProviderLocal
}
return (*Config)(cfg)
}
func (cfg *Config) GetProvider() (provider *Provider, res E.NestedError) {
b := E.NewBuilder("unable to initialize autocert")
defer b.To(&res)
if cfg.Provider != ProviderLocal {
if len(cfg.Domains) == 0 {
b.Addf("%s", "no domains specified")
}
if cfg.Provider == "" {
b.Addf("%s", "no provider specified")
}
if cfg.Email == "" {
b.Addf("%s", "no email specified")
}
// check if provider is implemented
_, ok := providersGenMap[cfg.Provider]
if !ok {
b.Addf("unknown provider: %q", cfg.Provider)
}
}
if b.HasError() {
return
}
privKey, err := E.Check(ecdsa.GenerateKey(elliptic.P256(), rand.Reader))
if err.HasError() {
b.Add(E.FailWith("generate private key", err))
return
}
user := &User{
Email: cfg.Email,
key: privKey,
}
legoCfg := lego.NewConfig(user)
legoCfg.Certificate.KeyType = certcrypto.RSA2048
provider = &Provider{
cfg: cfg,
user: user,
legoCfg: legoCfg,
}
return
}

View File

@@ -1,40 +0,0 @@
package autocert
import (
"errors"
"github.com/go-acme/lego/v4/providers/dns/clouddns"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/providers/dns/duckdns"
"github.com/go-acme/lego/v4/providers/dns/ovh"
"github.com/sirupsen/logrus"
)
const (
certBasePath = "certs/"
CertFileDefault = certBasePath + "cert.crt"
KeyFileDefault = certBasePath + "priv.key"
RegistrationFile = certBasePath + "registration.json"
)
const (
ProviderLocal = "local"
ProviderCloudflare = "cloudflare"
ProviderClouddns = "clouddns"
ProviderDuckdns = "duckdns"
ProviderOVH = "ovh"
)
var providersGenMap = map[string]ProviderGenerator{
ProviderLocal: providerGenerator(NewDummyDefaultConfig, NewDummyDNSProviderConfig),
ProviderCloudflare: providerGenerator(cloudflare.NewDefaultConfig, cloudflare.NewDNSProviderConfig),
ProviderClouddns: providerGenerator(clouddns.NewDefaultConfig, clouddns.NewDNSProviderConfig),
ProviderDuckdns: providerGenerator(duckdns.NewDefaultConfig, duckdns.NewDNSProviderConfig),
ProviderOVH: providerGenerator(ovh.NewDefaultConfig, ovh.NewDNSProviderConfig),
}
var (
ErrGetCertFailure = errors.New("get certificate failed")
)
var logger = logrus.WithField("module", "autocert")

View File

@@ -1,20 +0,0 @@
package autocert
type DummyConfig struct{}
type DummyProvider struct{}
func NewDummyDefaultConfig() *DummyConfig {
return &DummyConfig{}
}
func NewDummyDNSProviderConfig(*DummyConfig) (*DummyProvider, error) {
return &DummyProvider{}, nil
}
func (DummyProvider) Present(domain, token, keyAuth string) error {
return nil
}
func (DummyProvider) CleanUp(domain, token, keyAuth string) error {
return nil
}

View File

@@ -1,297 +0,0 @@
package autocert
import (
"context"
"crypto/tls"
"crypto/x509"
"os"
"path"
"reflect"
"sort"
"time"
"github.com/go-acme/lego/v4/certificate"
"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/internal/error"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
)
type (
Provider struct {
cfg *Config
user *User
legoCfg *lego.Config
client *lego.Client
tlsCert *tls.Certificate
certExpiries CertExpiries
}
ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.NestedError)
CertExpiries map[string]time.Time
)
func (p *Provider) GetCert(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.tlsCert == nil {
return nil, ErrGetCertFailure
}
return p.tlsCert, nil
}
func (p *Provider) GetName() string {
return p.cfg.Provider
}
func (p *Provider) GetCertPath() string {
return p.cfg.CertPath
}
func (p *Provider) GetKeyPath() string {
return p.cfg.KeyPath
}
func (p *Provider) GetExpiries() CertExpiries {
return p.certExpiries
}
func (p *Provider) ObtainCert() (res E.NestedError) {
b := E.NewBuilder("failed to obtain certificate")
defer b.To(&res)
if p.cfg.Provider == ProviderLocal {
return nil
}
if p.client == nil {
if err := p.initClient(); err.HasError() {
b.Add(E.FailWith("init autocert client", err))
return
}
}
if p.user.Registration == nil {
if err := p.registerACME(); err.HasError() {
b.Add(E.FailWith("register ACME", err))
return
}
}
client := p.client
req := certificate.ObtainRequest{
Domains: p.cfg.Domains,
Bundle: true,
}
cert, err := E.Check(client.Certificate.Obtain(req))
if err.HasError() {
b.Add(err)
return
}
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))
return
}
p.tlsCert = &tlsCert
p.certExpiries = expiries
return nil
}
func (p *Provider) LoadCert() E.NestedError {
cert, err := E.Check(tls.LoadX509KeyPair(p.cfg.CertPath, p.cfg.KeyPath))
if err.HasError() {
return err
}
expiries, err := getCertExpiries(&cert)
if err.HasError() {
return err
}
p.tlsCert = &cert
p.certExpiries = expiries
logger.Infof("next renewal in %v", U.FormatDuration(time.Until(p.ShouldRenewOn())))
return p.renewIfNeeded()
}
func (p *Provider) ShouldRenewOn() time.Time {
for _, expiry := range p.certExpiries {
return expiry.AddDate(0, -1, 0) // 1 month before
}
// this line should never be reached
panic("no certificate available")
}
func (p *Provider) ScheduleRenewal(ctx context.Context) {
if p.GetName() == ProviderLocal {
return
}
logger.Debug("started renewal scheduler")
defer logger.Debug("renewal scheduler stopped")
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C: // check every 5 seconds
if err := p.renewIfNeeded(); err.HasError() {
logger.Warn(err)
}
}
}
}
func (p *Provider) initClient() E.NestedError {
legoClient, err := E.Check(lego.NewClient(p.legoCfg))
if err.HasError() {
return E.FailWith("create lego client", err)
}
legoProvider, err := providersGenMap[p.cfg.Provider](p.cfg.Options)
if err.HasError() {
return E.FailWith("create lego provider", err)
}
err = E.From(legoClient.Challenge.SetDNS01Provider(legoProvider))
if err.HasError() {
return E.FailWith("set challenge provider", err)
}
p.client = legoClient
return nil
}
func (p *Provider) registerACME() E.NestedError {
if p.user.Registration != nil {
return nil
}
reg, err := E.Check(p.client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}))
if err.HasError() {
return err
}
p.user.Registration = reg
return nil
}
func (p *Provider) saveCert(cert *certificate.Resource) E.NestedError {
/* 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)
}
err = os.WriteFile(p.cfg.CertPath, cert.Certificate, 0o644) // -rw-r--r--
if err != nil {
return E.FailWith("write cert file", err)
}
return nil
}
func (p *Provider) certState() CertState {
if time.Now().After(p.ShouldRenewOn()) {
return CertStateExpired
}
certDomains := make([]string, len(p.certExpiries))
wantedDomains := make([]string, len(p.cfg.Domains))
i := 0
for domain := range p.certExpiries {
certDomains[i] = domain
i++
}
copy(wantedDomains, p.cfg.Domains)
sort.Strings(wantedDomains)
sort.Strings(certDomains)
if !reflect.DeepEqual(certDomains, wantedDomains) {
logger.Debugf("cert domains mismatch: %v != %v", certDomains, p.cfg.Domains)
return CertStateMismatch
}
return CertStateValid
}
func (p *Provider) renewIfNeeded() E.NestedError {
if p.cfg.Provider == ProviderLocal {
return nil
}
switch p.certState() {
case CertStateExpired:
logger.Info("certs expired, renewing")
case CertStateMismatch:
logger.Info("cert domains mismatch with config, renewing")
default:
return nil
}
if err := p.ObtainCert(); err.HasError() {
return E.FailWith("renew certificate", err)
}
return nil
}
func getCertExpiries(cert *tls.Certificate) (CertExpiries, E.NestedError) {
r := make(CertExpiries, len(cert.Certificate))
for _, cert := range cert.Certificate {
x509Cert, err := E.Check(x509.ParseCertificate(cert))
if err.HasError() {
return nil, E.FailWith("parse certificate", err)
}
if x509Cert.IsCA {
continue
}
r[x509Cert.Subject.CommonName] = x509Cert.NotAfter
for i := range x509Cert.DNSNames {
r[x509Cert.DNSNames[i]] = x509Cert.NotAfter
}
}
return r, nil
}
func providerGenerator[CT any, PT challenge.Provider](
defaultCfg func() *CT,
newProvider func(*CT) (PT, error),
) ProviderGenerator {
return func(opt types.AutocertProviderOpt) (challenge.Provider, E.NestedError) {
cfg := defaultCfg()
err := U.Deserialize(opt, cfg)
if err.HasError() {
return nil, err
}
p, err := E.Check(newProvider(cfg))
if err.HasError() {
return nil, err
}
return p, nil
}
}

View File

@@ -1,50 +0,0 @@
package provider_test
import (
"testing"
"github.com/go-acme/lego/v4/providers/dns/ovh"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
"gopkg.in/yaml.v3"
)
// type Config struct {
// APIEndpoint string
// ApplicationKey string
// ApplicationSecret string
// ConsumerKey string
// OAuth2Config *OAuth2Config
// PropagationTimeout time.Duration
// PollingInterval time.Duration
// TTL int
// HTTPClient *http.Client
// }
func TestOVH(t *testing.T) {
cfg := &ovh.Config{}
testYaml := `
api_endpoint: https://eu.api.ovh.com
application_key: <application_key>
application_secret: <application_secret>
consumer_key: <consumer_key>
oauth2_config:
client_id: <client_id>
client_secret: <client_secret>
`
cfgExpected := &ovh.Config{
APIEndpoint: "https://eu.api.ovh.com",
ApplicationKey: "<application_key>",
ApplicationSecret: "<application_secret>",
ConsumerKey: "<consumer_key>",
OAuth2Config: &ovh.OAuth2Config{ClientID: "<client_id>", ClientSecret: "<client_secret>"},
}
testYaml = testYaml[1:] // remove first \n
opt := make(map[string]any)
ExpectNoError(t, yaml.Unmarshal([]byte(testYaml), opt))
ExpectNoError(t, U.Deserialize(opt, cfg).Error())
ExpectDeepEqual(t, cfg, cfgExpected)
}

View File

@@ -1,29 +0,0 @@
package autocert
import (
"context"
"os"
E "github.com/yusing/go-proxy/internal/error"
)
func (p *Provider) Setup(ctx context.Context) (err E.NestedError) {
if err = p.LoadCert(); err != nil {
if !err.Is(os.ErrNotExist) { // ignore if cert doesn't exist
return err
}
logger.Debug("obtaining cert due to error loading cert")
if err = p.ObtainCert(); err != nil {
return err
}
}
go p.ScheduleRenewal(ctx)
for _, expiry := range p.GetExpiries() {
logger.Infof("certificate expire on %s", expiry)
break
}
return nil
}

View File

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

View File

@@ -1,23 +0,0 @@
package autocert
import (
"crypto"
"github.com/go-acme/lego/v4/registration"
)
type User struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (u *User) GetEmail() string {
return u.Email
}
func (u *User) GetRegistration() *registration.Resource {
return u.Registration
}
func (u *User) GetPrivateKey() crypto.PrivateKey {
return u.key
}

View File

@@ -1,57 +0,0 @@
package common
import (
"flag"
"fmt"
"github.com/sirupsen/logrus"
)
type Args struct {
Command string
}
const (
CommandStart = ""
CommandSetup = "setup"
CommandValidate = "validate"
CommandListConfigs = "ls-config"
CommandListRoutes = "ls-routes"
CommandListIcons = "ls-icons"
CommandReload = "reload"
CommandDebugListEntries = "debug-ls-entries"
CommandDebugListProviders = "debug-ls-providers"
CommandDebugListMTrace = "debug-ls-mtrace"
)
var ValidCommands = []string{
CommandStart,
CommandSetup,
CommandValidate,
CommandListConfigs,
CommandListRoutes,
CommandListIcons,
CommandReload,
CommandDebugListEntries,
CommandDebugListProviders,
CommandDebugListMTrace,
}
func GetArgs() Args {
var args Args
flag.Parse()
args.Command = flag.Arg(0)
if err := validateArg(args.Command); err != nil {
logrus.Fatal(err)
}
return args
}
func validateArg(arg string) error {
for _, v := range ValidCommands {
if arg == v {
return nil
}
}
return fmt.Errorf("invalid command: %s", arg)
}

View File

@@ -1,53 +0,0 @@
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
MiddlewareComposeBasePath = ConfigBasePath + "/middlewares"
)
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,
MiddlewareComposeBasePath,
}
const DockerHostFromEnv = "$DOCKER_HOST"
const (
IdleTimeoutDefault = "0"
WakeTimeoutDefault = "30s"
StopTimeoutDefault = "10s"
StopMethodDefault = "stop"
)

View File

@@ -1,66 +0,0 @@
package common
import (
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"github.com/sirupsen/logrus"
)
var (
NoSchemaValidation = GetEnvBool("GOPROXY_NO_SCHEMA_VALIDATION", true)
IsTest = GetEnvBool("GOPROXY_TEST", false) || strings.HasSuffix(os.Args[0], ".test")
IsDebug = GetEnvBool("GOPROXY_DEBUG", IsTest)
ProxyHTTPAddr,
ProxyHTTPHost,
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
}
b, err := strconv.ParseBool(value)
if err != nil {
log.Fatalf("Invalid boolean value: %s", value)
}
return b
}
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
}

View File

@@ -1,75 +0,0 @@
package common
var (
WellKnownHTTPPorts = map[string]bool{
"80": true,
"8000": true,
"8008": true,
"8080": true,
"3000": true,
}
ServiceNamePortMapTCP = map[string]int{
"mssql": 1433,
"mysql": 3306,
"mariadb": 3306,
"postgres": 5432,
"rabbitmq": 5672,
"redis": 6379,
"memcached": 11211,
"mongo": 27017,
"minecraft-server": 25565,
"ssh": 22,
"ftp": 21,
"smtp": 25,
"dns": 53,
"pop3": 110,
"imap": 143,
}
ImageNamePortMap = func() (m map[string]int) {
m = make(map[string]int, len(ServiceNamePortMapTCP)+len(imageNamePortMap))
for k, v := range ServiceNamePortMapTCP {
m[k] = v
}
for k, v := range imageNamePortMap {
m[k] = v
}
return
}()
imageNamePortMap = map[string]int{
"adguardhome": 3000,
"bazarr": 6767,
"calibre-web": 8083,
"changedetection.io": 3000,
"dockge": 5001,
"gitea": 3000,
"gogs": 3000,
"grafana": 3000,
"home-assistant": 8123,
"homebridge": 8581,
"httpd": 80,
"immich": 3001,
"jellyfin": 8096,
"lidarr": 8686,
"microbin": 8080,
"nginx": 80,
"nginx-proxy-manager": 81,
"open-webui": 8080,
"plex": 32400,
"portainer-be": 9443,
"portainer-ce": 9443,
"prometheus": 9090,
"prowlarr": 9696,
"radarr": 7878,
"radarr-sma": 7878,
"rsshub": 1200,
"rss-bridge": 80,
"sonarr": 8989,
"sonarr-sma": 8989,
"uptime-kuma": 3001,
"whisparr": 6969,
}
)

View File

@@ -1,235 +0,0 @@
package config
import (
"context"
"os"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/autocert"
"github.com/yusing/go-proxy/internal/common"
E "github.com/yusing/go-proxy/internal/error"
PR "github.com/yusing/go-proxy/internal/proxy/provider"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
W "github.com/yusing/go-proxy/internal/watcher"
"github.com/yusing/go-proxy/internal/watcher/events"
"gopkg.in/yaml.v3"
)
type Config struct {
value *types.Config
proxyProviders F.Map[string, *PR.Provider]
autocertProvider *autocert.Provider
l logrus.FieldLogger
watcher W.Watcher
watcherCtx context.Context
watcherCancel context.CancelFunc
reloadReq chan struct{}
}
var instance *Config
func GetInstance() *Config {
return instance
}
func Load() E.NestedError {
if instance != nil {
return nil
}
instance = &Config{
value: types.DefaultConfig(),
proxyProviders: F.NewMapOf[string, *PR.Provider](),
l: logrus.WithField("module", "config"),
watcher: W.NewConfigFileWatcher(common.ConfigFileName),
reloadReq: make(chan struct{}, 1),
}
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() types.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
}
func (cfg *Config) Dispose() {
if cfg.watcherCancel != nil {
cfg.watcherCancel()
cfg.l.Debug("stopped watcher")
}
cfg.stopProviders()
}
func (cfg *Config) Reload() (err E.NestedError) {
cfg.stopProviders()
err = cfg.load()
cfg.StartProxyProviders()
return
}
func (cfg *Config) StartProxyProviders() {
cfg.controlProviders("start", (*PR.Provider).StartAllRoutes)
}
func (cfg *Config) WatchChanges() {
cfg.watcherCtx, cfg.watcherCancel = context.WithCancel(context.Background())
go func() {
for {
select {
case <-cfg.watcherCtx.Done():
return
case <-cfg.reloadReq:
if err := cfg.Reload(); err.HasError() {
cfg.l.Error(err)
}
}
}
}()
go func() {
eventCh, errCh := cfg.watcher.Events(cfg.watcherCtx)
for {
select {
case <-cfg.watcherCtx.Done():
return
case event := <-eventCh:
if event.Action == events.ActionFileDeleted || event.Action == events.ActionFileRenamed {
cfg.l.Error("config file deleted or renamed, ignoring...")
continue
} else {
cfg.reloadReq <- struct{}{}
}
case err := <-errCh:
cfg.l.Error(err)
continue
}
}
}()
}
func (cfg *Config) forEachRoute(do func(alias string, r R.Route, p *PR.Provider)) {
cfg.proxyProviders.RangeAll(func(_ string, p *PR.Provider) {
p.RangeRoutes(func(a string, r R.Route) {
do(a, r, p)
})
})
}
func (cfg *Config) load() (res E.NestedError) {
b := E.NewBuilder("errors loading config")
defer b.To(&res)
cfg.l.Debug("loading config")
defer cfg.l.Debug("loaded config")
data, err := E.Check(os.ReadFile(common.ConfigPath))
if err.HasError() {
b.Add(E.FailWith("read config", err))
logrus.Fatal(b.Build())
}
if !common.NoSchemaValidation {
if err = Validate(data); err.HasError() {
b.Add(E.FailWith("schema validation", err))
logrus.Fatal(b.Build())
}
}
model := types.DefaultConfig()
if err := E.From(yaml.Unmarshal(data, model)); err.HasError() {
b.Add(E.FailWith("parse config", err))
logrus.Fatal(b.Build())
}
// errors are non fatal below
b.Add(cfg.initAutoCert(&model.AutoCert))
b.Add(cfg.loadProviders(&model.Providers))
cfg.value = model
R.SetFindMuxDomains(model.MatchDomains)
return
}
func (cfg *Config) initAutoCert(autocertCfg *types.AutoCertConfig) (err E.NestedError) {
if cfg.autocertProvider != nil {
return
}
cfg.l.Debug("initializing autocert")
defer cfg.l.Debug("initialized autocert")
cfg.autocertProvider, err = autocert.NewConfig(autocertCfg).GetProvider()
if err.HasError() {
err = E.FailWith("autocert provider", err)
}
return
}
func (cfg *Config) loadProviders(providers *types.ProxyProviders) (res E.NestedError) {
cfg.l.Debug("loading providers")
defer cfg.l.Debug("loaded providers")
b := E.NewBuilder("errors loading providers")
defer b.To(&res)
for _, filename := range providers.Files {
p, err := PR.NewFileProvider(filename)
if err != nil {
b.Add(err.Subject(filename))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes().Subject(filename))
}
for name, dockerHost := range providers.Docker {
p, err := PR.NewDockerProvider(name, dockerHost)
if err != nil {
b.Add(err.Subjectf("%s (%s)", name, dockerHost))
continue
}
cfg.proxyProviders.Store(p.GetName(), p)
b.Add(p.LoadRoutes().Subject(p.GetName()))
}
return
}
func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) {
errors := E.NewBuilder("errors in %s these providers", action)
cfg.proxyProviders.RangeAllParallel(func(name string, p *PR.Provider) {
if err := do(p); err.HasError() {
errors.Add(err.Subject(p))
}
})
if err := errors.Build(); err.HasError() {
cfg.l.Error(err)
}
}
func (cfg *Config) stopProviders() {
cfg.controlProviders("stop routes", (*PR.Provider).StopAllRoutes)
}

View File

@@ -1,150 +0,0 @@
package config
import (
"fmt"
"strings"
"github.com/yusing/go-proxy/internal/common"
H "github.com/yusing/go-proxy/internal/homepage"
PR "github.com/yusing/go-proxy/internal/proxy/provider"
R "github.com/yusing/go-proxy/internal/route"
"github.com/yusing/go-proxy/internal/types"
U "github.com/yusing/go-proxy/internal/utils"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
func (cfg *Config) DumpEntries() map[string]*types.RawEntry {
entries := make(map[string]*types.RawEntry)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
entries[alias] = r.Entry()
})
return entries
}
func (cfg *Config) DumpProviders() map[string]*PR.Provider {
entries := make(map[string]*PR.Provider)
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
entries[name] = p
})
return entries
}
func (cfg *Config) HomepageConfig() H.HomePageConfig {
var proto, port string
domains := cfg.value.MatchDomains
cert, _ := cfg.autocertProvider.GetCert(nil)
if cert != nil {
proto = "https"
port = common.ProxyHTTPSPort
} else {
proto = "http"
port = common.ProxyHTTPPort
}
hpCfg := H.NewHomePageConfig()
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
if !r.Started() {
return
}
entry := r.Entry()
if entry.Homepage == nil {
entry.Homepage = &H.HomePageItem{
Show: r.Entry().IsExplicit || !p.IsExplicitOnly(),
}
}
item := entry.Homepage
if !item.Show && !item.IsEmpty() {
item.Show = true
}
if !item.Show || r.Type() != R.RouteTypeReverseProxy {
return
}
if item.Name == "" {
item.Name = U.Title(
strings.ReplaceAll(
strings.ReplaceAll(alias, "-", " "),
"_", " ",
),
)
}
if p.GetType() == PR.ProviderTypeDocker {
if item.Category == "" {
item.Category = "Docker"
}
item.SourceType = string(PR.ProviderTypeDocker)
} else if p.GetType() == PR.ProviderTypeFile {
if item.Category == "" {
item.Category = "Others"
}
item.SourceType = string(PR.ProviderTypeFile)
}
if item.URL == "" {
if len(domains) > 0 {
item.URL = fmt.Sprintf("%s://%s.%s:%s", proto, strings.ToLower(alias), domains[0], port)
}
}
item.AltURL = r.URL().String()
hpCfg.Add(item)
})
return hpCfg
}
func (cfg *Config) RoutesByAlias() map[string]U.SerializedObject {
routes := make(map[string]U.SerializedObject)
cfg.forEachRoute(func(alias string, r R.Route, p *PR.Provider) {
if !r.Started() {
return
}
obj, err := U.Serialize(r)
if err.HasError() {
cfg.l.Error(err)
return
}
obj["provider"] = p.GetName()
obj["type"] = string(r.Type())
obj["started"] = r.Started()
obj["raw"] = r.Entry()
routes[alias] = obj
})
return routes
}
func (cfg *Config) Statistics() map[string]any {
nTotalStreams := 0
nTotalRPs := 0
providerStats := make(map[string]PR.ProviderStats)
cfg.proxyProviders.RangeAll(func(name string, p *PR.Provider) {
providerStats[name] = p.Statistics()
})
for _, stats := range providerStats {
nTotalRPs += stats.NumRPs
nTotalStreams += stats.NumStreams
}
return map[string]any{
"num_total_streams": nTotalStreams,
"num_total_reverse_proxies": nTotalRPs,
"providers": providerStats,
}
}
func (cfg *Config) FindRoute(alias string) R.Route {
return F.MapFind(cfg.proxyProviders,
func(p *PR.Provider) (R.Route, bool) {
if route, ok := p.GetRoute(alias); ok {
return route, true
}
return nil, false
},
)
}

View File

@@ -1,135 +0,0 @@
package docker
import (
"net/http"
"sync"
"sync/atomic"
"github.com/docker/cli/cli/connhelper"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
"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 {
key string
refCount *atomic.Int32
*client.Client
l logrus.FieldLogger
}
var (
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
clientMapMu sync.Mutex
clientOptEnvHost = []client.Opt{
client.WithHostFromEnv(),
client.WithAPIVersionNegotiation(),
}
)
func (c Client) Connected() bool {
return c.Client != nil
}
// if the client is still referenced, this is no-op.
func (c *Client) Close() error {
if c.refCount.Add(-1) > 0 {
return nil
}
clientMap.Delete(c.key)
client := c.Client
c.Client = nil
c.l.Debugf("client closed")
if client != nil {
return client.Close()
}
return nil
}
// ConnectClient creates a new Docker client connection to the specified host.
//
// Returns existing client if available.
//
// Parameters:
// - host: the host to connect to (either a URL or common.DockerHostFromEnv).
//
// Returns:
// - Client: the Docker client connection.
// - error: an error if the connection failed.
func ConnectClient(host string) (Client, E.NestedError) {
clientMapMu.Lock()
defer clientMapMu.Unlock()
// check if client exists
if client, ok := clientMap.Load(host); ok {
client.refCount.Add(1)
return client, nil
}
// create client
var opt []client.Opt
switch host {
case "":
return Client{}, E.Invalid("docker host", "empty")
case common.DockerHostFromEnv:
opt = clientOptEnvHost
default:
helper, err := E.Check(connhelper.GetConnectionHelper(host))
if err.HasError() {
return Client{}, E.UnexpectedError(err.Error())
}
if helper != nil {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: helper.Dialer,
},
}
opt = []client.Opt{
client.WithHTTPClient(httpClient),
client.WithHost(helper.Host),
client.WithAPIVersionNegotiation(),
client.WithDialContext(helper.Dialer),
}
} else {
opt = []client.Opt{
client.WithHost(host),
client.WithAPIVersionNegotiation(),
}
}
}
client, err := E.Check(client.NewClientWithOpts(opt...))
if err.HasError() {
return Client{}, err
}
c := Client{
Client: client,
key: host,
refCount: &atomic.Int32{},
l: logger.WithField("docker_client", client.DaemonHost()),
}
c.refCount.Add(1)
c.l.Debugf("client connected")
clientMap.Store(host, c)
return c, nil
}
func CloseAllClients() {
clientMap.RangeAllParallel(func(_ string, c Client) {
c.Client.Close()
})
clientMap.Clear()
logger.Debug("closed all clients")
}

View File

@@ -1,53 +0,0 @@
package docker
import (
"context"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
E "github.com/yusing/go-proxy/internal/error"
)
type ClientInfo struct {
Client Client
Containers []types.Container
}
var listOptions = container.ListOptions{
// Filters: filters.NewArgs(
// filters.Arg("health", "healthy"),
// filters.Arg("health", "none"),
// filters.Arg("health", "starting"),
// ),
All: true,
}
func GetClientInfo(clientHost string, getContainer bool) (*ClientInfo, E.NestedError) {
dockerClient, err := ConnectClient(clientHost)
if err.HasError() {
return nil, E.FailWith("connect to docker", err)
}
defer dockerClient.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var containers []types.Container
if getContainer {
containers, err = E.Check(dockerClient.ContainerList(ctx, listOptions))
if err.HasError() {
return nil, E.FailWith("list containers", err)
}
}
return &ClientInfo{
Client: dockerClient,
Containers: containers,
}, nil
}
func IsErrConnectionFailed(err error) bool {
return client.IsErrConnectionFailed(err)
}

View File

@@ -1,129 +0,0 @@
package docker
import (
"net/url"
"strconv"
"strings"
"github.com/docker/docker/api/types"
"github.com/sirupsen/logrus"
U "github.com/yusing/go-proxy/internal/utils"
)
type (
PortMapping = map[string]types.Port
Container struct {
_ U.NoCopy
DockerHost string `json:"docker_host" yaml:"-"`
ContainerName string `json:"container_name" yaml:"-"`
ContainerID string `json:"container_id" yaml:"-"`
ImageName string `json:"image_name" yaml:"-"`
Labels map[string]string `json:"labels" yaml:"-"`
PublicPortMapping PortMapping `json:"public_ports" yaml:"-"` // non-zero publicPort:types.Port
PrivatePortMapping PortMapping `json:"private_ports" yaml:"-"` // privatePort:types.Port
PublicIP string `json:"public_ip" yaml:"-"`
PrivateIP string `json:"private_ip" yaml:"-"`
NetworkMode string `json:"network_mode" yaml:"-"`
Aliases []string `json:"aliases" yaml:"-"`
IsExcluded bool `json:"is_excluded" yaml:"-"`
IsExplicit bool `json:"is_explicit" yaml:"-"`
IsDatabase bool `json:"is_database" yaml:"-"`
IdleTimeout string `json:"idle_timeout" yaml:"-"`
WakeTimeout string `json:"wake_timeout" yaml:"-"`
StopMethod string `json:"stop_method" yaml:"-"`
StopTimeout string `json:"stop_timeout" yaml:"-"` // stop_method = "stop" only
StopSignal string `json:"stop_signal" yaml:"-"` // stop_method = "stop" | "kill" only
Running bool `json:"running" yaml:"-"`
}
)
func FromDocker(c *types.Container, dockerHost string) (res *Container) {
isExplicit := c.Labels[LabelAliases] != ""
helper := containerHelper{c}
res = &Container{
DockerHost: dockerHost,
ContainerName: helper.getName(),
ContainerID: c.ID,
ImageName: helper.getImageName(),
Labels: c.Labels,
PublicPortMapping: helper.getPublicPortMapping(),
PrivatePortMapping: helper.getPrivatePortMapping(),
NetworkMode: c.HostConfig.NetworkMode,
Aliases: helper.getAliases(),
IsExcluded: U.ParseBool(helper.getDeleteLabel(LabelExclude)),
IsExplicit: isExplicit,
IsDatabase: helper.isDatabase(),
IdleTimeout: helper.getDeleteLabel(LabelIdleTimeout),
WakeTimeout: helper.getDeleteLabel(LabelWakeTimeout),
StopMethod: helper.getDeleteLabel(LabelStopMethod),
StopTimeout: helper.getDeleteLabel(LabelStopTimeout),
StopSignal: helper.getDeleteLabel(LabelStopSignal),
Running: c.Status == "running" || c.State == "running",
}
res.setPrivateIP(helper)
res.setPublicIP()
return
}
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.ParseUint(v.HostPort, 10, 16)
privPort, _ := strconv.ParseUint(k.Port(), 10, 16)
ports = append(ports, types.Port{
IP: v.HostIP,
PublicPort: uint16(pubPort),
PrivatePort: uint16(privPort),
})
}
}
cont := FromDocker(&types.Container{
ID: json.ID,
Names: []string{json.Name},
Image: json.Image,
Ports: ports,
Labels: json.Config.Labels,
State: json.State.Status,
Status: json.State.Status,
}, dockerHost)
cont.NetworkMode = string(json.HostConfig.NetworkMode)
return cont
}
func (c *Container) setPublicIP() {
if c.PublicPortMapping == nil {
return
}
if strings.HasPrefix(c.DockerHost, "unix://") {
c.PublicIP = "127.0.0.1"
return
}
url, err := url.Parse(c.DockerHost)
if err != nil {
logrus.Errorf("invalid docker host %q: %v\nfalling back to 127.0.0.1", c.DockerHost, err)
c.PublicIP = "127.0.0.1"
return
}
c.PublicIP = url.Hostname()
}
func (c *Container) setPrivateIP(helper containerHelper) {
if !strings.HasPrefix(c.DockerHost, "unix://") {
return
}
if helper.NetworkSettings == nil {
return
}
for _, v := range helper.NetworkSettings.Networks {
c.PrivateIP = v.IPAddress
return
}
}

View File

@@ -1,90 +0,0 @@
package docker
import (
"strings"
"github.com/docker/docker/api/types"
U "github.com/yusing/go-proxy/internal/utils"
)
type containerHelper struct {
*types.Container
}
// getDeleteLabel gets the value of a label and then deletes it from the container.
// If the label does not exist, an empty string is returned.
func (c containerHelper) getDeleteLabel(label string) string {
if l, ok := c.Labels[label]; ok {
delete(c.Labels, label)
return l
}
return ""
}
func (c containerHelper) getAliases() []string {
if l := c.getDeleteLabel(LabelAliases); l != "" {
return U.CommaSeperatedList(l)
}
return []string{c.getName()}
}
func (c containerHelper) getName() string {
return strings.TrimPrefix(c.Names[0], "/")
}
func (c containerHelper) getImageName() string {
colonSep := strings.Split(c.Image, ":")
slashSep := strings.Split(colonSep[0], "/")
return slashSep[len(slashSep)-1]
}
func (c containerHelper) getPublicPortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
if v.PublicPort == 0 {
continue
}
res[U.PortString(v.PublicPort)] = v
}
return res
}
func (c containerHelper) getPrivatePortMapping() PortMapping {
res := make(PortMapping)
for _, v := range c.Ports {
res[U.PortString(v.PrivatePort)] = v
}
return res
}
var databaseMPs = map[string]struct{}{
"/var/lib/postgresql/data": {},
"/var/lib/mysql": {},
"/var/lib/mongodb": {},
"/var/lib/mariadb": {},
"/var/lib/memcached": {},
"/var/lib/rabbitmq": {},
}
var databasePrivPorts = map[uint16]struct{}{
5432: {}, // postgres
3306: {}, // mysql, mariadb
6379: {}, // redis
11211: {}, // memcached
27017: {}, // mongodb
}
func (c containerHelper) isDatabase() bool {
for _, m := range c.Mounts {
if _, ok := databaseMPs[m.Destination]; ok {
return true
}
}
for _, v := range c.Ports {
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
return true
}
}
return false
}

View File

@@ -1,88 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.Title}}</title>
<style>
/* Global Styles */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, Arial, sans-serif;
font-size: 16px;
line-height: 1.5;
color: #fff;
background-color: #212121;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
/* Spinner Styles */
.spinner {
width: 120px;
height: 120px;
border: 16px solid #333;
border-radius: 50%;
border-top: 16px solid #66d9ef;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Error Styles */
.error {
display: inline-block;
text-align: center;
justify-content: center;
}
.error::before {
content: "\26A0"; /* Unicode for warning symbol */
font-size: 40px;
color: #ff9900;
}
/* Message Styles */
.message {
font-size: 24px;
font-weight: bold;
padding-left: 32px;
text-align: center;
}
</style>
</head>
<body>
<script>
window.onload = async function () {
let resp = await fetch(window.location.href, {
headers: {
"{{.CheckRedirectHeader}}": "1",
},
});
if (resp.ok) {
window.location.href = resp.url;
} else {
document.getElementById("message").innerText =
await resp.text();
document
.getElementById("spinner")
.classList.replace("spinner", "error");
}
};
</script>
<div id="spinner" class="spinner"></div>
<div id="message" class="message">{{.Message}}</div>
</body>
</html>

View File

@@ -1,38 +0,0 @@
package idlewatcher
import (
"bytes"
_ "embed"
"fmt"
"strings"
"text/template"
)
type templateData struct {
CheckRedirectHeader string
Title string
Message string
}
//go:embed html/loading_page.html
var loadingPage []byte
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
const headerCheckRedirect = "X-Goproxy-Check-Redirect"
func (w *Watcher) makeRespBody(format string, args ...any) []byte {
msg := fmt.Sprintf(format, args...)
data := new(templateData)
data.CheckRedirectHeader = headerCheckRedirect
data.Title = w.ContainerName
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
data.Message = strings.ReplaceAll(data.Message, " ", "&ensp;")
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
err := loadingPageTmpl.Execute(buf, data)
if err != nil { // should never happen in production
panic(err)
}
return buf.Bytes()
}

View File

@@ -1,133 +0,0 @@
package idlewatcher
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
type Waker struct {
*Watcher
client *http.Client
rp *gphttp.ReverseProxy
}
func NewWaker(w *Watcher, rp *gphttp.ReverseProxy) *Waker {
orig := rp.ServeHTTP
// workaround for stopped containers port become zero
rp.ServeHTTP = func(rw http.ResponseWriter, r *http.Request) {
if rp.TargetURL.Port() == "0" {
port, ok := portHistoryMap.Load(w.Alias)
if !ok {
w.l.Errorf("port history not found for %s", w.Alias)
http.Error(rw, "internal server error", http.StatusInternalServerError)
return
}
rp.TargetURL.Host = fmt.Sprintf("%s:%v", rp.TargetURL.Hostname(), port)
}
orig(rw, r)
}
return &Waker{
Watcher: w,
client: &http.Client{
Timeout: 1 * time.Second,
Transport: rp.Transport,
},
rp: rp,
}
}
func (w *Waker) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
w.wake(w.rp.ServeHTTP, rw, r)
}
func (w *Waker) wake(next http.HandlerFunc, rw http.ResponseWriter, r *http.Request) {
w.resetIdleTimer()
// pass through if container is ready
if w.ready.Load() {
next(rw, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), w.WakeTimeout)
defer cancel()
accept := gphttp.GetAccept(r.Header)
acceptHTML := (r.Method == http.MethodGet && accept.AcceptHTML() || r.RequestURI == "/" && accept.IsEmpty())
isCheckRedirect := r.Header.Get(headerCheckRedirect) != ""
if !isCheckRedirect && acceptHTML {
// Send a loading response to the client
body := w.makeRespBody("%s waking up...", w.ContainerName)
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.Header().Set("Content-Length", strconv.Itoa(len(body)))
rw.Header().Add("Cache-Control", "no-cache")
rw.Header().Add("Cache-Control", "no-store")
rw.Header().Add("Cache-Control", "must-revalidate")
if _, err := rw.Write(body); err != nil {
w.l.Errorf("error writing http response: %s", err)
}
return
}
// wake the container and reset idle timer
// also wait for another wake request
w.wakeCh <- struct{}{}
if <-w.wakeDone != nil {
http.Error(rw, "Error sending wake request", http.StatusInternalServerError)
return
}
// maybe another request came in while we were waiting for the wake
if w.ready.Load() {
if isCheckRedirect {
rw.WriteHeader(http.StatusOK)
} else {
next(rw, r)
}
return
}
for {
select {
case <-ctx.Done():
http.Error(rw, "Waking timed out", http.StatusGatewayTimeout)
return
default:
}
wakeReq, err := http.NewRequestWithContext(
ctx,
http.MethodHead,
w.URL.String(),
nil,
)
if err != nil {
w.l.Errorf("new request err to %s: %s", r.URL, err)
http.Error(rw, "Internal server error", http.StatusInternalServerError)
return
}
wakeResp, err := w.client.Do(wakeReq)
if err == nil && wakeResp.StatusCode != http.StatusServiceUnavailable {
w.ready.Store(true)
w.l.Debug("awaken")
if isCheckRedirect {
rw.WriteHeader(http.StatusOK)
} else {
next(rw, r)
}
return
}
// retry until the container is ready or timeout
time.Sleep(100 * time.Millisecond)
}
}

View File

@@ -1,282 +0,0 @@
package idlewatcher
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/docker/docker/api/types/container"
"github.com/sirupsen/logrus"
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"
F "github.com/yusing/go-proxy/internal/utils/functional"
W "github.com/yusing/go-proxy/internal/watcher"
)
type (
Watcher struct {
*P.ReverseProxyEntry
client D.Client
ready atomic.Bool // whether the site is ready to accept connection
stopByMethod StopCallback // send a docker command w.r.t. `stop_method`
wakeCh chan struct{}
wakeDone chan E.NestedError
ticker *time.Ticker
ctx context.Context
cancel context.CancelFunc
refCount *sync.WaitGroup
l logrus.FieldLogger
}
WakeDone <-chan error
WakeFunc func() WakeDone
StopCallback func() E.NestedError
)
var (
mainLoopCtx context.Context
mainLoopCancel context.CancelFunc
mainLoopWg sync.WaitGroup
watcherMap = F.NewMapOf[string, *Watcher]()
watcherMapMu sync.Mutex
portHistoryMap = F.NewMapOf[PT.Alias, string]()
newWatcherCh = make(chan *Watcher)
logger = logrus.WithField("module", "idle_watcher")
)
func Register(entry *P.ReverseProxyEntry) (*Watcher, E.NestedError) {
failure := E.Failure("idle_watcher register")
if entry.IdleTimeout == 0 {
return nil, failure.With(E.Invalid("idle_timeout", 0))
}
watcherMapMu.Lock()
defer watcherMapMu.Unlock()
key := entry.ContainerID
if entry.URL.Port() != "0" {
portHistoryMap.Store(entry.Alias, entry.URL.Port())
}
if w, ok := watcherMap.Load(key); ok {
w.refCount.Add(1)
w.ReverseProxyEntry = entry
return w, nil
}
client, err := D.ConnectClient(entry.DockerHost)
if err.HasError() {
return nil, failure.With(err)
}
w := &Watcher{
ReverseProxyEntry: entry,
client: client,
refCount: &sync.WaitGroup{},
wakeCh: make(chan struct{}, 1),
wakeDone: make(chan E.NestedError),
ticker: time.NewTicker(entry.IdleTimeout),
l: logger.WithField("container", entry.ContainerName),
}
w.refCount.Add(1)
w.stopByMethod = w.getStopCallback()
watcherMap.Store(key, w)
go func() {
newWatcherCh <- w
}()
return w, nil
}
func (w *Watcher) Unregister() {
w.refCount.Add(-1)
}
func Start() {
logger.Debug("started")
defer logger.Debug("stopped")
mainLoopCtx, mainLoopCancel = context.WithCancel(context.Background())
for {
select {
case <-mainLoopCtx.Done():
return
case w := <-newWatcherCh:
w.l.Debug("registered")
mainLoopWg.Add(1)
go func() {
w.watchUntilCancel()
w.refCount.Wait() // wait for 0 ref count
watcherMap.Delete(w.ContainerID)
w.l.Debug("unregistered")
mainLoopWg.Done()
}()
}
}
}
func Stop() {
mainLoopCancel()
mainLoopWg.Wait()
}
func (w *Watcher) containerStop() error {
return w.client.ContainerStop(w.ctx, w.ContainerID, container.StopOptions{
Signal: string(w.StopSignal),
Timeout: &w.StopTimeout,
})
}
func (w *Watcher) containerPause() error {
return w.client.ContainerPause(w.ctx, w.ContainerID)
}
func (w *Watcher) containerKill() error {
return w.client.ContainerKill(w.ctx, w.ContainerID, string(w.StopSignal))
}
func (w *Watcher) containerUnpause() error {
return w.client.ContainerUnpause(w.ctx, w.ContainerID)
}
func (w *Watcher) containerStart() error {
return w.client.ContainerStart(w.ctx, w.ContainerID, container.StartOptions{})
}
func (w *Watcher) containerStatus() (string, E.NestedError) {
json, err := w.client.ContainerInspect(w.ctx, w.ContainerID)
if err != nil {
return "", E.FailWith("inspect container", err)
}
return json.State.Status, nil
}
func (w *Watcher) wakeIfStopped() E.NestedError {
if w.ready.Load() || w.ContainerRunning {
return nil
}
status, err := w.containerStatus()
if err.HasError() {
return err
}
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch status {
case "exited", "dead":
return E.From(w.containerStart())
case "paused":
return E.From(w.containerUnpause())
case "running":
return nil
default:
return E.Unexpected("container state", status)
}
}
func (w *Watcher) getStopCallback() StopCallback {
var cb func() error
switch w.StopMethod {
case PT.StopMethodPause:
cb = w.containerPause
case PT.StopMethodStop:
cb = w.containerStop
case PT.StopMethodKill:
cb = w.containerKill
default:
panic("should not reach here")
}
return func() E.NestedError {
status, err := w.containerStatus()
if err.HasError() {
return err
}
if status != "running" {
return nil
}
return E.From(cb())
}
}
func (w *Watcher) resetIdleTimer() {
w.ticker.Reset(w.IdleTimeout)
}
func (w *Watcher) watchUntilCancel() {
defer close(w.wakeCh)
w.ctx, w.cancel = context.WithCancel(mainLoopCtx)
dockerWatcher := W.NewDockerWatcherWithClient(w.client)
dockerEventCh, dockerEventErrCh := dockerWatcher.EventsWithOptions(w.ctx, W.DockerListOptions{
Filters: W.NewDockerFilter(
W.DockerFilterContainer,
W.DockerrFilterContainer(w.ContainerID),
W.DockerFilterStart,
W.DockerFilterStop,
W.DockerFilterDie,
W.DockerFilterKill,
W.DockerFilterPause,
W.DockerFilterUnpause,
),
})
defer w.ticker.Stop()
defer w.client.Close()
for {
select {
case <-w.ctx.Done():
w.l.Debug("stopped")
return
case err := <-dockerEventErrCh:
if err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("docker watcher", err))
}
case e := <-dockerEventCh:
switch {
// create / start / unpause
case e.Action.IsContainerWake():
w.ContainerRunning = true
w.resetIdleTimer()
w.l.Info(e)
default: // stop / pause / kil
w.ContainerRunning = false
w.ticker.Stop()
w.ready.Store(false)
w.l.Info(e)
}
case <-w.ticker.C:
w.l.Debug("idle timeout")
w.ticker.Stop()
if err := w.stopByMethod(); err != nil && err.IsNot(context.Canceled) {
w.l.Error(E.FailWith("stop", err).Extraf("stop method: %s", w.StopMethod))
}
case <-w.wakeCh:
w.l.Debug("wake signal received")
w.resetIdleTimer()
err := w.wakeIfStopped()
if err != nil {
w.l.Error(E.FailWith("wake", err))
}
w.wakeDone <- err
}
}
}

View File

@@ -1,19 +0,0 @@
package docker
import (
"context"
"time"
E "github.com/yusing/go-proxy/internal/error"
)
func (c Client) Inspect(containerID string) (*Container, E.NestedError) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
json, err := c.ContainerInspect(ctx, containerID)
if err != nil {
return nil, E.From(err)
}
return FromJSON(json, c.key), nil
}

View File

@@ -1,115 +0,0 @@
package docker
import (
"reflect"
"strings"
E "github.com/yusing/go-proxy/internal/error"
U "github.com/yusing/go-proxy/internal/utils"
)
/*
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
)
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 := range reflect.TypeFor[T]().NumField() {
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 {
if field.Kind() == reflect.Ptr {
if field.IsNil() {
field.Set(reflect.New(field.Type().Elem()))
}
} else {
field = field.Addr()
}
return U.Deserialize(U.SerializedObject{nestedLabel.Namespace: nestedLabel.Value}, field.Interface())
}
if dst == nil {
field.Set(reflect.MakeMap(reflect.TypeFor[NestedLabelMap]()))
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
}
return l, nil
}

View File

@@ -1,83 +0,0 @@
package docker
import (
"fmt"
"testing"
U "github.com/yusing/go-proxy/internal/utils"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
const (
mName = "middleware1"
mAttr = "prop1"
v = "value1"
)
func makeLabel(ns, name, attr string) string {
return fmt.Sprintf("%s.%s.%s", ns, name, attr)
}
func TestNestedLabel(t *testing.T) {
mAttr := "prop1"
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", mName, mAttr)), v)
ExpectNoError(t, err.Error())
sGot := ExpectType[*Label](t, pl.Value)
ExpectFalse(t, sGot == nil)
ExpectEqual(t, sGot.Namespace, mName)
ExpectEqual(t, sGot.Attribute, mAttr)
}
func TestApplyNestedLabel(t *testing.T) {
entry := new(struct {
Middlewares NestedLabelMap `yaml:"middlewares"`
})
pl, err := ParseLabel(makeLabel(NSProxy, "foo", makeLabel("middlewares", 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) {
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", makeLabel("middlewares", 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) {
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", "middlewares", mName)), v)
ExpectNoError(t, err.Error())
err = ApplyLabel(entry, pl)
ExpectNoError(t, err.Error())
_, ok := entry.Middlewares[mName]
ExpectTrue(t, ok)
}

View File

@@ -1,16 +0,0 @@
package docker
const (
WildcardAlias = "*"
NSProxy = "proxy"
NSHomePage = "homepage"
LabelAliases = NSProxy + ".aliases"
LabelExclude = NSProxy + ".exclude"
LabelIdleTimeout = NSProxy + ".idle_timeout"
LabelWakeTimeout = NSProxy + ".wake_timeout"
LabelStopMethod = NSProxy + ".stop_method"
LabelStopTimeout = NSProxy + ".stop_timeout"
LabelStopSignal = NSProxy + ".stop_signal"
)

View File

@@ -1,5 +0,0 @@
package docker
import "github.com/sirupsen/logrus"
var logger = logrus.WithField("module", "docker")

View File

@@ -1,82 +0,0 @@
package error
import (
"fmt"
"strings"
"sync"
)
type Builder struct {
*builder
}
type builder struct {
message string
errors []NestedError
sync.Mutex
}
func NewBuilder(format string, args ...any) Builder {
return Builder{&builder{message: fmt.Sprintf(format, args...)}}
}
// adding nil / nil is no-op,
// you may safely pass expressions returning error to it.
func (b Builder) Add(err NestedError) Builder {
if err != nil {
b.Lock()
b.errors = append(b.errors, err)
b.Unlock()
}
return b
}
func (b Builder) AddE(err error) Builder {
return b.Add(From(err))
}
func (b Builder) Addf(format string, args ...any) Builder {
return b.Add(errorf(format, args...))
}
func (b Builder) AddRangeE(errs ...error) Builder {
for _, err := range errs {
b.AddE(err)
}
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.
// Otherwise, it returns a NestedError with the message and the errors collected.
//
// Returns:
// - NestedError: the built NestedError.
func (b Builder) Build() NestedError {
if len(b.errors) == 0 {
return nil
} else if len(b.errors) == 1 && !strings.ContainsRune(b.message, ' ') {
return b.errors[0].Subject(b.message)
}
return Join(b.message, b.errors...)
}
func (b Builder) To(ptr *NestedError) {
switch {
case ptr == nil:
return
case *ptr == nil:
*ptr = b.Build()
default:
(*ptr).extras = append((*ptr).extras, *b.Build())
}
}
func (b Builder) String() string {
return b.Build().String()
}
func (b Builder) HasError() bool {
return len(b.errors) > 0
}

View File

@@ -1,52 +0,0 @@
package error_test
import (
"testing"
. "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestBuilderEmpty(t *testing.T) {
eb := NewBuilder("qwer")
ExpectTrue(t, eb.Build() == nil)
ExpectTrue(t, eb.Build().NoError())
ExpectFalse(t, eb.HasError())
}
func TestBuilderAddNil(t *testing.T) {
eb := NewBuilder("asdf")
var err NestedError
for range 3 {
eb.Add(nil)
}
for range 3 {
eb.Add(err)
}
ExpectTrue(t, eb.Build() == nil)
ExpectTrue(t, eb.Build().NoError())
ExpectFalse(t, eb.HasError())
}
func TestBuilderNested(t *testing.T) {
eb := NewBuilder("error occurred")
eb.Add(Failure("Action 1").With(Invalid("Inner", "1")).With(Invalid("Inner", "2")))
eb.Add(Failure("Action 2").With(Invalid("Inner", "3")))
got := eb.Build().String()
expected1 := (`error occurred:
- Action 1 failed:
- invalid Inner: 1
- invalid Inner: 2
- Action 2 failed:
- invalid Inner: 3`)
expected2 := (`error occurred:
- Action 1 failed:
- invalid Inner: "1"
- invalid Inner: "2"
- Action 2 failed:
- invalid Inner: "3"`)
if got != expected1 && got != expected2 {
t.Errorf("expected \n%s, got \n%s", expected1, got)
}
}

View File

@@ -1,294 +0,0 @@
package error
import (
"encoding/json"
"errors"
"fmt"
"strings"
)
type (
NestedError = *NestedErrorImpl
NestedErrorImpl struct {
subject string
err error
extras []NestedErrorImpl
}
JSONNestedError struct {
Subject string `json:"subject"`
Err string `json:"error"`
Extras []JSONNestedError `json:"extras,omitempty"`
}
)
func From(err error) NestedError {
if IsNil(err) {
return nil
}
return &NestedErrorImpl{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([]NestedErrorImpl, len(j.Extras))
for i, e := range j.Extras {
extra, ok := fromJSONObject(e)
if !ok {
return nil, false
}
extras[i] = *extra
}
return &NestedErrorImpl{
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) {
return obj, From(err)
}
func Join(message string, err ...NestedError) NestedError {
extras := make([]NestedErrorImpl, len(err))
nErr := 0
for i, e := range err {
if e == nil {
continue
}
extras[i] = *e
nErr++
}
if nErr == 0 {
return nil
}
return &NestedErrorImpl{
err: errors.New(message),
extras: extras,
}
}
func JoinE(message string, err ...error) NestedError {
b := NewBuilder("%s", message)
for _, e := range err {
b.AddE(e)
}
return b.Build()
}
func IsNil(err error) bool {
return err == nil
}
func IsNotNil(err error) bool {
return err != nil
}
func (ne NestedError) String() string {
var buf strings.Builder
ne.writeToSB(&buf, 0, "")
return buf.String()
}
func (ne NestedError) Is(err error) bool {
if ne == nil {
return err == nil
}
// return errors.Is(ne.err, err)
if errors.Is(ne.err, err) {
return true
}
for _, e := range ne.extras {
if e.Is(err) {
return true
}
}
return false
}
func (ne NestedError) IsNot(err error) bool {
return !ne.Is(err)
}
func (ne NestedError) Error() error {
if ne == nil {
return nil
}
return ne.buildError(0, "")
}
func (ne NestedError) With(s any) NestedError {
if ne == nil {
return ne
}
var msg string
switch ss := s.(type) {
case nil:
return ne
case NestedError:
return ne.withError(ss)
case error:
return ne.withError(From(ss))
case string:
msg = ss
case fmt.Stringer:
return ne.appendMsg(ss.String())
default:
return ne.appendMsg(fmt.Sprint(s))
}
return ne.withError(From(errors.New(msg)))
}
func (ne NestedError) Extraf(format string, args ...any) NestedError {
return ne.With(errorf(format, args...))
}
func (ne NestedError) Subject(s any, sep ...string) NestedError {
if ne == nil {
return ne
}
var subject string
switch ss := s.(type) {
case string:
subject = ss
case fmt.Stringer:
subject = ss.String()
default:
subject = fmt.Sprint(s)
}
switch {
case ne.subject == "":
ne.subject = subject
case len(sep) > 0:
ne.subject = fmt.Sprintf("%s%s%s", subject, sep[0], ne.subject)
default:
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
}
return ne
}
func (ne NestedError) Subjectf(format string, args ...any) NestedError {
if ne == nil {
return ne
}
return ne.Subject(fmt.Sprintf(format, args...))
}
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,
}
}
func (ne NestedError) JSON() []byte {
b, err := json.MarshalIndent(ne.JSONObject(), "", " ")
if err != nil {
panic(err)
}
return b
}
func (ne NestedError) NoError() bool {
return ne == nil
}
func (ne NestedError) HasError() bool {
return ne != nil
}
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)
}
return ne
}
func (ne NestedError) appendMsg(msg string) NestedError {
if ne == nil {
return nil
}
ne.err = fmt.Errorf("%w %s", ne.err, msg)
return ne
}
func (ne NestedError) writeToSB(sb *strings.Builder, level int, prefix string) {
for range level {
sb.WriteString(" ")
}
sb.WriteString(prefix)
if ne.NoError() {
sb.WriteString("nil")
return
}
sb.WriteString(ne.err.Error())
if ne.subject != "" {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
}
if len(ne.extras) > 0 {
sb.WriteRune(':')
for _, extra := range ne.extras {
sb.WriteRune('\n')
extra.writeToSB(sb, level+1, "- ")
}
}
}
func (ne NestedError) buildError(level int, prefix string) error {
var res error
var sb strings.Builder
for range level {
sb.WriteString(" ")
}
sb.WriteString(prefix)
if ne.NoError() {
sb.WriteString("nil")
return errors.New(sb.String())
}
res = fmt.Errorf("%s%w", sb.String(), ne.err)
sb.Reset()
if ne.subject != "" {
sb.WriteString(fmt.Sprintf(" for %q", ne.subject))
}
if len(ne.extras) > 0 {
sb.WriteRune(':')
res = fmt.Errorf("%w%s", res, sb.String())
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

@@ -1,108 +0,0 @@
package error_test
import (
"errors"
"testing"
. "github.com/yusing/go-proxy/internal/error"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestErrorIs(t *testing.T) {
ExpectTrue(t, Failure("foo").Is(ErrFailure))
ExpectTrue(t, Failure("foo").With("bar").Is(ErrFailure))
ExpectFalse(t, Failure("foo").With("bar").Is(ErrInvalid))
ExpectFalse(t, Failure("foo").With("bar").With("baz").Is(ErrInvalid))
ExpectTrue(t, Invalid("foo", "bar").Is(ErrInvalid))
ExpectFalse(t, Invalid("foo", "bar").Is(ErrFailure))
ExpectFalse(t, Invalid("foo", "bar").Is(nil))
ExpectTrue(t, errors.Is(Failure("foo").Error(), ErrFailure))
ExpectTrue(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrInvalid))
ExpectTrue(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrFailure))
ExpectFalse(t, errors.Is(Failure("foo").With(Invalid("bar", "baz")).Error(), ErrNotExists))
}
func TestErrorNestedIs(t *testing.T) {
var err NestedError
ExpectTrue(t, err.Is(nil))
err = Failure("some reason")
ExpectTrue(t, err.Is(ErrFailure))
ExpectFalse(t, err.Is(ErrDuplicated))
err.With(Duplicated("something", ""))
ExpectTrue(t, err.Is(ErrFailure))
ExpectTrue(t, err.Is(ErrDuplicated))
ExpectFalse(t, err.Is(ErrInvalid))
}
func TestIsNil(t *testing.T) {
var err NestedError
ExpectTrue(t, err.Is(nil))
ExpectFalse(t, err.HasError())
ExpectTrue(t, err == nil)
ExpectTrue(t, err.NoError())
eb := NewBuilder("")
returnNil := func() error {
return eb.Build().Error()
}
ExpectTrue(t, IsNil(returnNil()))
ExpectTrue(t, returnNil() == nil)
ExpectTrue(t, (err.
Subject("any").
With("something").
Extraf("foo %s", "bar")) == nil)
}
func TestErrorSimple(t *testing.T) {
ne := Failure("foo bar")
ExpectEqual(t, ne.String(), "foo bar failed")
ne = ne.Subject("baz")
ExpectEqual(t, ne.String(), "foo bar failed for \"baz\"")
}
func TestErrorWith(t *testing.T) {
ne := Failure("foo").With("bar").With("baz")
ExpectEqual(t, ne.String(), "foo failed:\n - bar\n - baz")
}
func TestErrorNested(t *testing.T) {
inner := Failure("inner").
With("1").
With("1")
inner2 := Failure("inner2").
Subject("action 2").
With("2").
With("2")
inner3 := Failure("inner3").
Subject("action 3").
With("3").
With("3")
ne := Failure("foo").
With("bar").
With("baz").
With(inner).
With(inner.With(inner2.With(inner3)))
want := `foo failed:
- bar
- baz
- inner failed:
- 1
- 1
- inner failed:
- 1
- 1
- inner2 failed for "action 2":
- 2
- 2
- inner3 failed for "action 3":
- 3
- 3`
ExpectEqual(t, ne.String(), want)
ExpectEqual(t, ne.Error().Error(), want)
}

View File

@@ -1,77 +0,0 @@
package error
import (
stderrors "errors"
"reflect"
)
var (
ErrFailure = stderrors.New("failed")
ErrInvalid = stderrors.New("invalid")
ErrUnsupported = stderrors.New("unsupported")
ErrUnexpected = stderrors.New("unexpected")
ErrNotExists = stderrors.New("does not exist")
ErrMissing = stderrors.New("missing")
ErrDuplicated = stderrors.New("duplicated")
ErrOutOfRange = stderrors.New("out of range")
ErrTypeError = stderrors.New("type error")
ErrTypeMismatch = stderrors.New("type mismatch")
)
const fmtSubjectWhat = "%w %v: %q"
func Failure(what string) NestedError {
return errorf("%s %w", what, ErrFailure)
}
func FailedWhy(what string, why string) NestedError {
return Failure(what).With(why)
}
func FailWith(what string, err any) NestedError {
return Failure(what).With(err)
}
func Invalid(subject, what any) NestedError {
return errorf(fmtSubjectWhat, ErrInvalid, subject, what)
}
func Unsupported(subject, what any) NestedError {
return errorf(fmtSubjectWhat, ErrUnsupported, subject, what)
}
func Unexpected(subject, what any) NestedError {
return errorf(fmtSubjectWhat, ErrUnexpected, subject, what)
}
func UnexpectedError(err error) NestedError {
return errorf("%w error: %w", ErrUnexpected, err)
}
func NotExist(subject, what any) NestedError {
return errorf("%v %w: %v", subject, ErrNotExists, what)
}
func Missing(subject any) NestedError {
return errorf("%w %v", ErrMissing, subject)
}
func Duplicated(subject, what any) NestedError {
return errorf("%w %v: %v", ErrDuplicated, subject, what)
}
func OutOfRange(subject any, value any) NestedError {
return errorf("%v %w: %v", subject, ErrOutOfRange, value)
}
func TypeError(subject any, from, to reflect.Type) NestedError {
return errorf("%v %w: %s -> %s\n", subject, ErrTypeError, from, to)
}
func TypeError2(subject any, from, to reflect.Value) NestedError {
return TypeError(subject, from.Type(), to.Type())
}
func TypeMismatch[Expect any](value any) NestedError {
return errorf("%w: expect %s got %T", ErrTypeMismatch, reflect.TypeFor[Expect](), value)
}

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
package http
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,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
DefaultTransportNoTLS = func() *http.Transport {
var clone = DefaultTransport.Clone()
clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return clone
}()
)
const StaticFilePathPrefix = "/$gperrorpage/"

View File

@@ -1,76 +0,0 @@
package http
import (
"mime"
"net/http"
)
type ContentType string
type AcceptContentType []ContentType
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 GetAccept(h http.Header) AcceptContentType {
var accepts []ContentType
for _, v := range h["Accept"] {
ct, _, err := mime.ParseMediaType(v)
if err != nil {
continue
}
accepts = append(accepts, ContentType(ct))
}
return accepts
}
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"
}
func (act AcceptContentType) IsEmpty() bool {
return len(act) == 0
}
func (act AcceptContentType) AcceptHTML() bool {
for _, v := range act {
if v.IsHTML() || v == "text/*" || v == "*/*" {
return true
}
}
return false
}
func (act AcceptContentType) AcceptJSON() bool {
for _, v := range act {
if v.IsJSON() || v == "*/*" {
return true
}
}
return false
}
func (act AcceptContentType) AcceptPlainText() bool {
for _, v := range act {
if v.IsPlainText() || v == "text/*" || v == "*/*" {
return true
}
}
return false
}

View File

@@ -1,41 +0,0 @@
package http
import (
"net/http"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestContentTypes(t *testing.T) {
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsHTML())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/html; charset=utf-8"}}).IsHTML())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/xhtml+xml"}}).IsHTML())
ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/plain"}}).IsHTML())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/json"}}).IsJSON())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"application/json; charset=utf-8"}}).IsJSON())
ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsJSON())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/plain"}}).IsPlainText())
ExpectTrue(t, GetContentType(http.Header{"Content-Type": {"text/plain; charset=utf-8"}}).IsPlainText())
ExpectFalse(t, GetContentType(http.Header{"Content-Type": {"text/html"}}).IsPlainText())
}
func TestAcceptContentTypes(t *testing.T) {
ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain"}}).AcceptPlainText())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain; charset=utf-8"}}).AcceptPlainText())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/html", "text/plain"}}).AcceptHTML())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"application/json"}}).AcceptJSON())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptPlainText())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptHTML())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"*/*"}}).AcceptJSON())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptPlainText())
ExpectTrue(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptHTML())
ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/plain"}}).AcceptHTML())
ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/plain; charset=utf-8"}}).AcceptHTML())
ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/html"}}).AcceptPlainText())
ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/html"}}).AcceptJSON())
ExpectFalse(t, GetAccept(http.Header{"Accept": {"text/*"}}).AcceptJSON())
}

View File

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

View File

@@ -1,33 +0,0 @@
package loadbalancer
import (
"hash/fnv"
"net"
"net/http"
)
type ipHash struct{ *LoadBalancer }
func (lb *LoadBalancer) newIPHash() impl { return &ipHash{lb} }
func (ipHash) OnAddServer(srv *Server) {}
func (ipHash) OnRemoveServer(srv *Server) {}
func (impl ipHash) ServeHTTP(_ servers, rw http.ResponseWriter, r *http.Request) {
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(rw, "Internal error", http.StatusInternalServerError)
logger.Errorf("invalid remote address %s: %s", r.RemoteAddr, err)
return
}
idx := hashIP(ip) % uint32(len(impl.pool))
if !impl.pool[idx].available.Load() {
http.Error(rw, "Service unavailable", http.StatusServiceUnavailable)
}
impl.pool[idx].handler.ServeHTTP(rw, r)
}
func hashIP(ip string) uint32 {
h := fnv.New32a()
h.Write([]byte(ip))
return h.Sum32()
}

View File

@@ -1,53 +0,0 @@
package loadbalancer
import (
"net/http"
"sync/atomic"
F "github.com/yusing/go-proxy/internal/utils/functional"
)
type leastConn struct {
*LoadBalancer
nConn F.Map[*Server, *atomic.Int64]
}
func (lb *LoadBalancer) newLeastConn() impl {
return &leastConn{
LoadBalancer: lb,
nConn: F.NewMapOf[*Server, *atomic.Int64](),
}
}
func (impl *leastConn) OnAddServer(srv *Server) {
impl.nConn.Store(srv, new(atomic.Int64))
}
func (impl *leastConn) OnRemoveServer(srv *Server) {
impl.nConn.Delete(srv)
}
func (impl *leastConn) ServeHTTP(srvs servers, rw http.ResponseWriter, r *http.Request) {
srv := srvs[0]
minConn, ok := impl.nConn.Load(srv)
if !ok {
logger.Errorf("[BUG] server %s not found", srv.Name)
http.Error(rw, "Internal error", http.StatusInternalServerError)
}
for i := 1; i < len(srvs); i++ {
nConn, ok := impl.nConn.Load(srvs[i])
if !ok {
logger.Errorf("[BUG] server %s not found", srv.Name)
http.Error(rw, "Internal error", http.StatusInternalServerError)
}
if nConn.Load() < minConn.Load() {
minConn = nConn
srv = srvs[i]
}
}
minConn.Add(1)
srv.handler.ServeHTTP(rw, r)
minConn.Add(-1)
}

View File

@@ -1,238 +0,0 @@
package loadbalancer
import (
"context"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/v4/log"
E "github.com/yusing/go-proxy/internal/error"
)
// TODO: stats of each server.
// TODO: support weighted mode.
type (
impl interface {
ServeHTTP(srvs servers, rw http.ResponseWriter, r *http.Request)
OnAddServer(srv *Server)
OnRemoveServer(srv *Server)
}
Config struct {
Link string
Mode Mode
Weight weightType
}
LoadBalancer struct {
impl
Config
pool servers
poolMu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
done chan struct{}
sumWeight weightType
}
weightType uint16
)
const maxWeight weightType = 100
func New(cfg Config) *LoadBalancer {
lb := &LoadBalancer{Config: cfg, pool: servers{}}
mode := cfg.Mode
if !cfg.Mode.ValidateUpdate() {
logger.Warnf("loadbalancer %s: invalid mode %q, fallback to %s", cfg.Link, mode, cfg.Mode)
}
switch mode {
case RoundRobin:
lb.impl = lb.newRoundRobin()
case LeastConn:
lb.impl = lb.newLeastConn()
case IPHash:
lb.impl = lb.newIPHash()
default: // should happen in test only
lb.impl = lb.newRoundRobin()
}
return lb
}
func (lb *LoadBalancer) AddServer(srv *Server) {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
lb.pool = append(lb.pool, srv)
lb.sumWeight += srv.Weight
lb.impl.OnAddServer(srv)
logger.Debugf("[add] loadbalancer %s: %d servers available", lb.Link, len(lb.pool))
}
func (lb *LoadBalancer) RemoveServer(srv *Server) {
lb.poolMu.RLock()
defer lb.poolMu.RUnlock()
lb.impl.OnRemoveServer(srv)
for i, s := range lb.pool {
if s == srv {
lb.pool = append(lb.pool[:i], lb.pool[i+1:]...)
break
}
}
if lb.IsEmpty() {
lb.Stop()
return
}
lb.Rebalance()
logger.Debugf("[remove] loadbalancer %s: %d servers left", lb.Link, len(lb.pool))
}
func (lb *LoadBalancer) IsEmpty() bool {
return len(lb.pool) == 0
}
func (lb *LoadBalancer) Rebalance() {
if lb.sumWeight == maxWeight {
return
}
if lb.sumWeight == 0 { // distribute evenly
weightEach := maxWeight / weightType(len(lb.pool))
remainder := maxWeight % weightType(len(lb.pool))
for _, s := range lb.pool {
s.Weight = weightEach
lb.sumWeight += weightEach
if remainder > 0 {
s.Weight++
remainder--
}
}
return
}
// scale evenly
scaleFactor := float64(maxWeight) / float64(lb.sumWeight)
lb.sumWeight = 0
for _, s := range lb.pool {
s.Weight = weightType(float64(s.Weight) * scaleFactor)
lb.sumWeight += s.Weight
}
delta := maxWeight - lb.sumWeight
if delta == 0 {
return
}
for _, s := range lb.pool {
if delta == 0 {
break
}
if delta > 0 {
s.Weight++
lb.sumWeight++
delta--
} else {
s.Weight--
lb.sumWeight--
delta++
}
}
}
func (lb *LoadBalancer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
srvs := lb.availServers()
if len(srvs) == 0 {
http.Error(rw, "Service unavailable", http.StatusServiceUnavailable)
return
}
lb.impl.ServeHTTP(srvs, rw, r)
}
func (lb *LoadBalancer) Start() {
if lb.sumWeight != 0 && lb.sumWeight != maxWeight {
msg := E.NewBuilder("loadbalancer %s total weight %d != %d", lb.Link, lb.sumWeight, maxWeight)
for _, s := range lb.pool {
msg.Addf("%s: %d", s.Name, s.Weight)
}
lb.Rebalance()
inner := E.NewBuilder("after rebalancing")
for _, s := range lb.pool {
inner.Addf("%s: %d", s.Name, s.Weight)
}
msg.Addf("%s", inner)
logger.Warn(msg)
}
if lb.sumWeight != 0 {
log.Warnf("weighted mode not supported yet")
}
lb.done = make(chan struct{}, 1)
lb.ctx, lb.cancel = context.WithCancel(context.Background())
updateAll := func() {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
var wg sync.WaitGroup
wg.Add(len(lb.pool))
for _, s := range lb.pool {
go func(s *Server) {
defer wg.Done()
s.checkUpdateAvail(lb.ctx)
}(s)
}
wg.Wait()
}
logger.Debugf("loadbalancer %s started", lb.Link)
go func() {
defer lb.cancel()
defer close(lb.done)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
updateAll()
for {
select {
case <-lb.ctx.Done():
return
case <-ticker.C:
updateAll()
}
}
}()
}
func (lb *LoadBalancer) Stop() {
if lb.cancel == nil {
return
}
lb.cancel()
<-lb.done
lb.pool = nil
logger.Debugf("loadbalancer %s stopped", lb.Link)
}
func (lb *LoadBalancer) availServers() servers {
lb.poolMu.Lock()
defer lb.poolMu.Unlock()
avail := servers{}
for _, s := range lb.pool {
if s.available.Load() {
avail = append(avail, s)
}
}
return avail
}

View File

@@ -1,43 +0,0 @@
package loadbalancer
import (
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestRebalance(t *testing.T) {
t.Parallel()
t.Run("zero", func(t *testing.T) {
lb := New(Config{})
for range 10 {
lb.AddServer(&Server{})
}
lb.Rebalance()
ExpectEqual(t, lb.sumWeight, maxWeight)
})
t.Run("less", func(t *testing.T) {
lb := New(Config{})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .1)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .2)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .3)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .2)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .1)})
lb.Rebalance()
// t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " ")))
ExpectEqual(t, lb.sumWeight, maxWeight)
})
t.Run("more", func(t *testing.T) {
lb := New(Config{})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .1)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .2)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .3)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .4)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .3)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .2)})
lb.AddServer(&Server{Weight: weightType(float64(maxWeight) * .1)})
lb.Rebalance()
// t.Logf("%s", U.Must(json.MarshalIndent(lb.pool, "", " ")))
ExpectEqual(t, lb.sumWeight, maxWeight)
})
}

View File

@@ -1,5 +0,0 @@
package loadbalancer
import "github.com/sirupsen/logrus"
var logger = logrus.WithField("module", "load_balancer")

View File

@@ -1,29 +0,0 @@
package loadbalancer
import (
U "github.com/yusing/go-proxy/internal/utils"
)
type Mode string
const (
RoundRobin Mode = "roundrobin"
LeastConn Mode = "leastconn"
IPHash Mode = "iphash"
)
func (mode *Mode) ValidateUpdate() bool {
switch U.ToLowerNoSnake(string(*mode)) {
case "", string(RoundRobin):
*mode = RoundRobin
return true
case string(LeastConn):
*mode = LeastConn
return true
case string(IPHash):
*mode = IPHash
return true
}
*mode = RoundRobin
return false
}

View File

@@ -1,22 +0,0 @@
package loadbalancer
import (
"net/http"
"sync/atomic"
)
type roundRobin struct {
index atomic.Uint32
}
func (*LoadBalancer) newRoundRobin() impl { return &roundRobin{} }
func (lb *roundRobin) OnAddServer(srv *Server) {}
func (lb *roundRobin) OnRemoveServer(srv *Server) {}
func (lb *roundRobin) ServeHTTP(srvs servers, rw http.ResponseWriter, r *http.Request) {
index := lb.index.Add(1)
srvs[index%uint32(len(srvs))].handler.ServeHTTP(rw, r)
if lb.index.Load() >= 2*uint32(len(srvs)) {
lb.index.Store(0)
}
}

View File

@@ -1,67 +0,0 @@
package loadbalancer
import (
"context"
"net/http"
"sync/atomic"
"time"
"github.com/yusing/go-proxy/internal/net/types"
)
type (
Server struct {
Name string
URL types.URL
Weight weightType
handler http.Handler
pinger *http.Client
available atomic.Bool
}
servers []*Server
)
func NewServer(name string, url types.URL, weight weightType, handler http.Handler) *Server {
srv := &Server{
Name: name,
URL: url,
Weight: weight,
handler: handler,
pinger: &http.Client{Timeout: 3 * time.Second},
}
srv.available.Store(true)
return srv
}
func (srv *Server) checkUpdateAvail(ctx context.Context) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodHead,
srv.URL.String(),
nil,
)
if err != nil {
logger.Error("failed to create request: ", err)
srv.available.Store(false)
}
resp, err := srv.pinger.Do(req)
if err == nil && resp.StatusCode != http.StatusServiceUnavailable {
if !srv.available.Swap(true) {
logger.Infof("server %s is up", srv.Name)
}
} else if err != nil {
if srv.available.Swap(false) {
logger.Warnf("server %s is down: %s", srv.Name, err)
}
} else {
if srv.available.Swap(false) {
logger.Warnf("server %s is down: status %s", srv.Name, resp.Status)
}
}
}
func (srv *Server) String() string {
return srv.Name
}

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
package middleware
import (
"bytes"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/sirupsen/logrus"
"github.com/yusing/go-proxy/internal/api/v1/errorpage"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
var CustomErrorPage = &Middleware{
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
if !ServeStaticErrorPageFile(w, r) {
next(w, r)
}
},
modifyResponse: func(resp *Response) error {
// only handles non-success status code and html/plain content type
contentType := gphttp.GetContentType(resp.Header)
if !gphttp.IsSuccess(resp.StatusCode) && (contentType.IsHTML() || contentType.IsPlainText()) {
errorPage, ok := errorpage.GetErrorPageByStatus(resp.StatusCode)
if ok {
errPageLogger.Debugf("error page for status %d loaded", resp.StatusCode)
/* trunk-ignore(golangci-lint/errcheck) */
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", strconv.Itoa(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, gphttp.StaticFilePathPrefix) {
filename := path[len(gphttp.StaticFilePathPrefix):]
file, ok := errorpage.GetStaticFile(filename)
if !ok {
errPageLogger.Errorf("unable to load resource %s", filename)
return false
}
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)
}
if _, err := w.Write(file); err != nil {
errPageLogger.WithError(err).Errorf("unable to write resource %s", filename)
http.Error(w, "Error page failure", http.StatusInternalServerError)
}
return true
}
return false
}
var errPageLogger = logrus.WithField("middleware", "error_page")

View File

@@ -1,233 +0,0 @@
// 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"
E "github.com/yusing/go-proxy/internal/error"
gphttp "github.com/yusing/go-proxy/internal/net/http"
)
type (
forwardAuth struct {
*forwardAuthOpts
m *Middleware
client http.Client
}
forwardAuthOpts struct {
Address string
TrustForwardHeader bool
AuthResponseHeaders []string
AddAuthCookiesToResponse []string
transport http.RoundTripper
}
)
var ForwardAuth = &forwardAuth{
m: &Middleware{withOptions: NewForwardAuthfunc},
}
func NewForwardAuthfunc(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
fa := new(forwardAuth)
fa.forwardAuthOpts = new(forwardAuthOpts)
err := Deserialize(optsRaw, fa.forwardAuthOpts)
if err != nil {
return nil, err
}
_, err = E.Check(url.Parse(fa.Address))
if err != nil {
return nil, E.Invalid("address", fa.Address)
}
fa.m = &Middleware{
impl: fa,
before: fa.forward,
}
// TODO: use tr from reverse proxy
tr, ok := fa.transport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
tr = gphttp.DefaultTransport.Clone()
}
fa.client = http.Client{
CheckRedirect: func(r *Request, via []*Request) error {
return http.ErrUseLastResponse
},
Timeout: 30 * time.Second,
Transport: tr,
}
return fa.m, nil
}
func (fa *forwardAuth) forward(next http.HandlerFunc, w ResponseWriter, req *Request) {
gphttp.RemoveHop(req.Header)
faReq, err := http.NewRequestWithContext(
req.Context(),
http.MethodGet,
fa.Address,
nil,
)
if err != nil {
fa.m.AddTracef("new request err to %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
gphttp.CopyHeader(faReq.Header, req.Header)
gphttp.RemoveHop(faReq.Header)
faReq.Header = gphttp.FilterHeaders(faReq.Header, fa.AuthResponseHeaders)
fa.setAuthHeaders(req, faReq)
fa.m.AddTraceRequest("forward auth request", faReq)
faResp, err := fa.client.Do(faReq)
if err != nil {
fa.m.AddTracef("failed to call %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
defer faResp.Body.Close()
body, err := io.ReadAll(faResp.Body)
if err != nil {
fa.m.AddTracef("failed to read response body from %s", fa.Address).WithError(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if faResp.StatusCode < http.StatusOK || faResp.StatusCode >= http.StatusMultipleChoices {
fa.m.AddTraceResponse("forward auth response", faResp)
gphttp.CopyHeader(w.Header(), faResp.Header)
gphttp.RemoveHop(w.Header())
redirectURL, err := faResp.Location()
if err != nil {
fa.m.AddTracef("failed to get location from %s", fa.Address).WithError(err).WithResponse(faResp)
w.WriteHeader(http.StatusInternalServerError)
return
} else if redirectURL.String() != "" {
w.Header().Set("Location", redirectURL.String())
fa.m.AddTracef("redirect to %q", redirectURL.String()).WithResponse(faResp)
}
w.WriteHeader(faResp.StatusCode)
if _, err = w.Write(body); err != nil {
fa.m.AddTracef("failed to write response body from %s", fa.Address).WithError(err).WithResponse(faResp)
}
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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
package middleware
import (
"slices"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestSetModifyRequest(t *testing.T) {
opts := OptionsRaw{
"set_headers": map[string]string{"User-Agent": "go-proxy/v0.5.0"},
"add_headers": map[string]string{"Accept-Encoding": "test-value"},
"hide_headers": []string{"Accept"},
}
t.Run("set_options", func(t *testing.T) {
mr, err := ModifyRequest.m.WithOptionsClone(opts)
ExpectNoError(t, err.Error())
ExpectDeepEqual(t, mr.impl.(*modifyRequest).SetHeaders, opts["set_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyRequest).AddHeaders, opts["add_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyRequest).HideHeaders, opts["hide_headers"].([]string))
})
t.Run("request_headers", func(t *testing.T) {
result, err := newMiddlewareTest(ModifyRequest.m, &testArgs{
middlewareOpt: opts,
})
ExpectNoError(t, err.Error())
ExpectEqual(t, result.RequestHeaders.Get("User-Agent"), "go-proxy/v0.5.0")
ExpectTrue(t, slices.Contains(result.RequestHeaders.Values("Accept-Encoding"), "test-value"))
ExpectEqual(t, result.RequestHeaders.Get("Accept"), "")
})
}

View File

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

View File

@@ -1,35 +0,0 @@
package middleware
import (
"slices"
"testing"
. "github.com/yusing/go-proxy/internal/utils/testing"
)
func TestSetModifyResponse(t *testing.T) {
opts := OptionsRaw{
"set_headers": map[string]string{"User-Agent": "go-proxy/v0.5.0"},
"add_headers": map[string]string{"Accept-Encoding": "test-value"},
"hide_headers": []string{"Accept"},
}
t.Run("set_options", func(t *testing.T) {
mr, err := ModifyResponse.m.WithOptionsClone(opts)
ExpectNoError(t, err.Error())
ExpectDeepEqual(t, mr.impl.(*modifyResponse).SetHeaders, opts["set_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyResponse).AddHeaders, opts["add_headers"].(map[string]string))
ExpectDeepEqual(t, mr.impl.(*modifyResponse).HideHeaders, opts["hide_headers"].([]string))
})
t.Run("request_headers", func(t *testing.T) {
result, err := newMiddlewareTest(ModifyResponse.m, &testArgs{
middlewareOpt: opts,
})
ExpectNoError(t, err.Error())
ExpectEqual(t, result.ResponseHeaders.Get("User-Agent"), "go-proxy/v0.5.0")
t.Log(result.ResponseHeaders.Get("Accept-Encoding"))
ExpectTrue(t, slices.Contains(result.ResponseHeaders.Values("Accept-Encoding"), "test-value"))
ExpectEqual(t, result.ResponseHeaders.Get("Accept"), "")
})
}

View File

@@ -1,129 +0,0 @@
package middleware
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"reflect"
E "github.com/yusing/go-proxy/internal/error"
)
type oAuth2 struct {
*oAuth2Opts
m *Middleware
}
type oAuth2Opts struct {
ClientID string
ClientSecret string
AuthURL string // Authorization Endpoint
TokenURL string // Token Endpoint
}
var OAuth2 = &oAuth2{
m: &Middleware{withOptions: NewAuthentikOAuth2},
}
func NewAuthentikOAuth2(opts OptionsRaw) (*Middleware, E.NestedError) {
oauth := new(oAuth2)
oauth.m = &Middleware{
impl: oauth,
before: oauth.handleOAuth2,
}
oauth.oAuth2Opts = &oAuth2Opts{}
err := Deserialize(opts, oauth.oAuth2Opts)
if err != nil {
return nil, err
}
b := E.NewBuilder("missing required fields")
optV := reflect.ValueOf(oauth.oAuth2Opts)
for _, field := range reflect.VisibleFields(reflect.TypeFor[oAuth2Opts]()) {
if optV.FieldByName(field.Name).Len() == 0 {
b.Add(E.Missing(field.Name))
}
}
if b.HasError() {
return nil, b.Build().Subject("oAuth2")
}
return oauth.m, nil
}
func (oauth *oAuth2) handleOAuth2(next http.HandlerFunc, rw ResponseWriter, r *Request) {
// Check if the user is authenticated (you may use session, cookie, etc.)
if !userIsAuthenticated(r) {
// TODO: Redirect to OAuth2 auth URL
http.Redirect(rw, r, fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code",
oauth.oAuth2Opts.AuthURL, oauth.oAuth2Opts.ClientID, ""), http.StatusFound)
return
}
// If you have a token in the query string, process it
if code := r.URL.Query().Get("code"); code != "" {
// Exchange the authorization code for a token here
// Use the TokenURL and authenticate the user
token, err := exchangeCodeForToken(code, oauth.oAuth2Opts, r.RequestURI)
if err != nil {
// handle error
http.Error(rw, "failed to get token", http.StatusUnauthorized)
return
}
// Save token and user info based on your requirements
saveToken(rw, token)
// Redirect to the originally requested URL
http.Redirect(rw, r, "/", http.StatusFound)
return
}
// If user is authenticated, go to the next handler
next(rw, r)
}
func userIsAuthenticated(r *http.Request) bool {
// Example: Check for a session or cookie
session, err := r.Cookie("session_token")
if err != nil || session.Value == "" {
return false
}
// Validate the session_token if necessary
return true
}
func exchangeCodeForToken(code string, opts *oAuth2Opts, requestURI string) (string, error) {
// Prepare the request body
data := url.Values{
"client_id": {opts.ClientID},
"client_secret": {opts.ClientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {requestURI},
}
resp, err := http.PostForm(opts.TokenURL, data)
if err != nil {
return "", fmt.Errorf("failed to request token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("received non-ok status from token endpoint: %s", resp.Status)
}
// Decode the response
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("failed to decode token response: %w", err)
}
return tokenResp.AccessToken, nil
}
func saveToken(rw ResponseWriter, token string) {
// Example: Save token in cookie
http.SetCookie(rw, &http.Cookie{
Name: "auth_token",
Value: token,
// set other properties as necessary, such as Secure and HttpOnly
})
}

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
package middleware
import (
"net/http"
"github.com/yusing/go-proxy/internal/common"
)
var RedirectHTTP = &Middleware{
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
if r.TLS == nil {
r.URL.Scheme = "https"
r.URL.Host = r.URL.Hostname() + ":" + common.ProxyHTTPSPort
http.Redirect(w, r, r.URL.String(), http.StatusTemporaryRedirect)
return
}
next(w, r)
},
}

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