mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-21 16:31:43 +02:00
Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929b7f7059 | ||
|
|
de7805f281 | ||
|
|
03cad9f315 | ||
|
|
aa6fafd52f | ||
|
|
01ff63a007 | ||
|
|
99746bad8e | ||
|
|
21b67e97af | ||
|
|
668639e484 | ||
|
|
e9b2079599 | ||
|
|
5fb7d21c80 | ||
|
|
f5e00a6ef4 | ||
|
|
b06cbc0fee | ||
|
|
abbcbad5e9 | ||
|
|
fab39a461f | ||
|
|
9c3edff92b | ||
|
|
e8f4cd18a4 | ||
|
|
e566fd9b57 | ||
|
|
6211ddcdf0 | ||
|
|
245f073350 | ||
|
|
dd629f516b | ||
|
|
31080edd59 | ||
|
|
b679655cd5 | ||
|
|
ca3b062f89 | ||
|
|
de6c1be51b | ||
|
|
4f09dbf044 | ||
|
|
e6b4630ce9 | ||
|
|
90bababd38 | ||
|
|
90130411f9 | ||
|
|
ae61a2335d | ||
|
|
8329a8ea9c | ||
|
|
ef52ccb929 | ||
|
|
ed9d8aab6f | ||
|
|
aa16287447 | ||
|
|
a7a922308e | ||
|
|
ba13b81b0e | ||
|
|
d172552fb0 | ||
|
|
2a8ab27fc1 | ||
|
|
e8c3e4c75f | ||
|
|
ed887a5cfc | ||
|
|
1bac96dc2a | ||
|
|
c3b779a810 | ||
|
|
44cfd65f6c | ||
|
|
f5a36f94bb | ||
|
|
e951194bee | ||
|
|
478311fe9e | ||
|
|
48dd1397e8 | ||
|
|
ebedbc931f | ||
|
|
9065d990e5 | ||
|
|
b38d7595a7 | ||
|
|
860e914b90 | ||
|
|
ac3af49aa7 | ||
|
|
415f169f48 | ||
|
|
e2b08d8667 | ||
|
|
91e7f4894a | ||
|
|
a78dba5191 | ||
|
|
c7208c90c6 | ||
|
|
da6a2756fa | ||
|
|
9a6a66f5a8 | ||
|
|
90487bfde6 | ||
|
|
4120fd8d1c | ||
|
|
6f3a5ebe6e | ||
|
|
a935f200a3 | ||
|
|
f474ae4f75 | ||
|
|
345a4417a6 | ||
|
|
8cca83723c | ||
|
|
aa2fcd47c2 | ||
|
|
0580a7d3cd | ||
|
|
a43c242c66 | ||
|
|
45d4b92fc6 | ||
|
|
72df9ff3e4 | ||
|
|
48bf31fd0e | ||
|
|
4ee5383f7d | ||
|
|
33fb60a32d | ||
|
|
d10d0e49fa | ||
|
|
dc3575c8fd | ||
|
|
17115cfb0b | ||
|
|
498082f7e5 | ||
|
|
99216ffe59 | ||
|
|
f426dbc9cf | ||
|
|
1c611cc9b9 | ||
|
|
dc43e26770 | ||
|
|
79ae26f1b5 | ||
|
|
109c2460fa | ||
|
|
71e8e4a462 | ||
|
|
8e2cc56afb | ||
|
|
6728bc39d2 | ||
|
|
daca4b7735 | ||
|
|
3b597eea29 | ||
|
|
090b73d287 | ||
|
|
96bce79e4b | ||
|
|
d9fd399e43 | ||
|
|
46281aa3b0 | ||
|
|
d39b68bfd8 | ||
|
|
a11ce46028 | ||
|
|
6388d9d44d | ||
|
|
69361aea1b | ||
|
|
26e2154c64 | ||
|
|
a29bf880bc | ||
|
|
1f6d03bdbb | ||
|
|
4a7d898b8e | ||
|
|
521b694aec | ||
|
|
a351de7441 | ||
|
|
ab2dc26b76 | ||
|
|
9a81b13b67 | ||
|
|
626bd9666b | ||
|
|
d7eab2ebcd | ||
|
|
e48b9bbb0a | ||
|
|
339411530b | ||
|
|
4a2d42bfa9 | ||
|
|
81da9ad83a | ||
|
|
be7a766cb2 | ||
|
|
83d1d027c6 | ||
|
|
21fcceb391 | ||
|
|
82f06374f7 | ||
|
|
04fd6543fd | ||
|
|
409a18df38 | ||
|
|
4e5a8d0985 | ||
|
|
16b507bc7c | ||
|
|
1120991019 | ||
|
|
c0ebd9f8c0 | ||
|
|
996b418ea9 | ||
|
|
4cddd4ff71 | ||
|
|
7a0478164f | ||
|
|
2e7ba51521 | ||
|
|
5be8659a99 | ||
|
|
719693deb7 | ||
|
|
23e7d06081 | ||
|
|
85fb637551 | ||
|
|
2fc82c3790 | ||
|
|
a5a31a0d63 | ||
|
|
73e481bc96 | ||
|
|
93359110a2 | ||
|
|
24778d1093 | ||
|
|
830d0bdadd | ||
|
|
e12b356d0d | ||
|
|
52549b6446 | ||
|
|
8694987ef9 | ||
|
|
b125b14bf6 | ||
|
|
c782f365f9 | ||
|
|
72418a2056 | ||
|
|
03bf425a38 | ||
|
|
5fafa619ee | ||
|
|
bebf99ed6c | ||
|
|
8483263d01 | ||
|
|
351bf84559 | ||
|
|
cbe23d2ed1 | ||
|
|
6e45f3683c | ||
|
|
581894c05b | ||
|
|
2657b1f726 | ||
|
|
3505e8ff7e | ||
|
|
2314e39291 | ||
|
|
bd19f443d4 | ||
|
|
ce433f0c51 | ||
|
|
47877e5119 | ||
|
|
486122f3d8 | ||
|
|
a0be1f11d3 | ||
|
|
662190e09e | ||
|
|
ce1e5da72e | ||
|
|
eb7e744a75 | ||
|
|
ac26baf97f | ||
|
|
5a8c11de16 | ||
|
|
a8ecafcd09 | ||
|
|
af37d1f29e | ||
|
|
8cfd24e6bd | ||
|
|
7bf5784016 | ||
|
|
25930a1a73 | ||
|
|
f20a1ff523 | ||
|
|
ba51796a64 | ||
|
|
c445d50221 | ||
|
|
73dfc17a82 | ||
|
|
fdab026a3b | ||
|
|
c789c69c86 | ||
|
|
2b298aa7fa | ||
|
|
d20e4d435a | ||
|
|
15d9436d52 | ||
|
|
ca98b31458 | ||
|
|
77f957c7a8 | ||
|
|
51493c9fdd | ||
|
|
9b34dc994d | ||
|
|
6bc4c1c49a | ||
|
|
443dd99b5b | ||
|
|
db6f857aaf | ||
|
|
6a54fc85ac | ||
|
|
90f4aac946 | ||
|
|
539ef911de | ||
|
|
fff790b527 | ||
|
|
094f75ef46 | ||
|
|
43ecd80687 | ||
|
|
e7f6abf027 | ||
|
|
22f911c30f | ||
|
|
5272829582 | ||
|
|
48a9e312f5 | ||
|
|
f09b152cf9 | ||
|
|
8184eb5aff | ||
|
|
b37e201ea8 | ||
|
|
ad9fc3cfe5 | ||
|
|
264ac4886d | ||
|
|
50eb5e9eb1 |
128
.github/workflows/docker-image.yml
vendored
Normal file
128
.github/workflows/docker-image.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
name: Docker Image CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build multi-platform Docker image
|
||||||
|
runs-on: self-hosted
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform:
|
||||||
|
- linux/amd64
|
||||||
|
# - linux/arm/v6
|
||||||
|
# - linux/arm/v7
|
||||||
|
- linux/arm64
|
||||||
|
steps:
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
platform=${{ matrix.platform }}
|
||||||
|
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
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: self-hosted
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create manifest list and push
|
||||||
|
id: push
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect image
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
|
||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -1,7 +1,22 @@
|
|||||||
compose.yml
|
compose.yml
|
||||||
go-proxy.yml
|
|
||||||
config.yml
|
config*/
|
||||||
providers.yml
|
certs*/
|
||||||
bin/go-proxy.bak
|
bin/
|
||||||
|
error_pages/
|
||||||
|
|
||||||
logs/
|
logs/
|
||||||
log/
|
log/
|
||||||
|
|
||||||
|
.vscode/settings.json
|
||||||
|
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
!cmd/**/
|
||||||
|
!internal/**/
|
||||||
|
|
||||||
|
todo.md
|
||||||
|
|
||||||
|
.*.swp
|
||||||
|
.aider*
|
||||||
|
mtrace.json
|
||||||
|
|||||||
15
.gitlab-ci.yml
Normal file
15
.gitlab-ci.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
build-image:
|
||||||
|
image: docker
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH
|
||||||
|
variables:
|
||||||
|
CI_REGISTRY_IMAGE: $CI_REGISTRY_IMAGE:latest
|
||||||
|
- if: $CI_COMMIT_REF_NAME != $CI_DEFAULT_BRANCH
|
||||||
|
variables:
|
||||||
|
CI_REGISTRY_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_BRANCH
|
||||||
|
before_script:
|
||||||
|
- 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
|
||||||
0
.gitmodules
vendored
Normal file
0
.gitmodules
vendored
Normal file
11
.vscode/settings.example.json
vendored
Normal file
11
.vscode/settings.example.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"go.inferGopath": false
|
|
||||||
}
|
|
||||||
62
Dockerfile
62
Dockerfile
@@ -1,22 +1,58 @@
|
|||||||
FROM alpine:latest
|
# Stage 1: Builder
|
||||||
|
FROM golang:1.23.1-alpine AS builder
|
||||||
|
RUN apk add --no-cache tzdata make
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Only copy go.mod and go.sum initially for better caching
|
||||||
|
COPY go.mod go.sum /src/
|
||||||
|
|
||||||
|
# Utilize build cache
|
||||||
|
RUN --mount=type=cache,target="/go/pkg/mod" \
|
||||||
|
go mod download -x
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Stage 2: Final image
|
||||||
|
FROM scratch
|
||||||
|
|
||||||
LABEL maintainer="yusing@6uo.me"
|
LABEL maintainer="yusing@6uo.me"
|
||||||
|
LABEL proxy.exclude=1
|
||||||
|
|
||||||
RUN apk add --no-cache bash tzdata
|
# copy timezone data
|
||||||
RUN mkdir /app
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
COPY bin/go-proxy entrypoint.sh /app/
|
|
||||||
COPY templates/ /app/templates
|
|
||||||
COPY config.default.yml /app/config.yml
|
|
||||||
|
|
||||||
RUN chmod +x /app/go-proxy /app/entrypoint.sh
|
# copy binary
|
||||||
ENV DOCKER_HOST unix:///var/run/docker.sock
|
COPY --from=builder /app /app
|
||||||
ENV GOPROXY_DEBUG 0
|
|
||||||
ENV GOPROXY_REDIRECT_HTTP 1
|
# 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
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
EXPOSE 8080
|
EXPOSE 8888
|
||||||
EXPOSE 443
|
EXPOSE 443
|
||||||
EXPOSE 8443
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENTRYPOINT /app/entrypoint.sh
|
|
||||||
|
CMD ["/app/go-proxy"]
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 [fullname]
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
69
Makefile
69
Makefile
@@ -1,35 +1,58 @@
|
|||||||
.PHONY: all build up quick-restart restart logs get udp-server
|
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
|
||||||
|
|
||||||
all: build quick-restart logs
|
.PHONY: all setup build test up restart logs get debug run archive repush rapid-crash debug-list-containers
|
||||||
|
|
||||||
|
all: debug
|
||||||
|
|
||||||
build:
|
build:
|
||||||
mkdir -p bin
|
scripts/build.sh
|
||||||
CGO_ENABLED=0 GOOS=linux go build -pgo=auto -o bin/go-proxy src/go-proxy/*.go
|
|
||||||
|
test:
|
||||||
|
GOPROXY_TEST=1 go test ./internal/...
|
||||||
|
|
||||||
up:
|
up:
|
||||||
docker compose up -d --build go-proxy
|
docker compose up -d
|
||||||
|
|
||||||
quick-restart: # quick restart without restarting the container
|
|
||||||
docker cp bin/go-proxy go-proxy:/app/go-proxy
|
|
||||||
docker cp templates/* go-proxy:/app/templates
|
|
||||||
docker cp entrypoint.sh go-proxy:/app/entrypoint.sh
|
|
||||||
docker exec -d go-proxy bash /app/entrypoint.sh restart
|
|
||||||
|
|
||||||
restart:
|
restart:
|
||||||
docker kill go-proxy
|
docker compose restart -t 0
|
||||||
docker compose up -d go-proxy
|
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
tail -f log/go-proxy.log
|
docker compose logs -f
|
||||||
|
|
||||||
get:
|
get:
|
||||||
go get -d -u ./src/go-proxy
|
go get -u ./cmd && go mod tidy
|
||||||
|
|
||||||
udp-server:
|
debug:
|
||||||
docker run -it --rm \
|
make build && sudo GOPROXY_DEBUG=1 bin/go-proxy
|
||||||
-p 9999:9999/udp \
|
|
||||||
--label proxy.test-udp.scheme=udp \
|
run-test:
|
||||||
--label proxy.test-udp.port=20003:9999 \
|
make build && sudo GOPROXY_TEST=1 bin/go-proxy
|
||||||
--network data_default \
|
|
||||||
--name test-udp \
|
run:
|
||||||
$$(docker build -q -f udp-test-server.Dockerfile .)
|
make build && sudo bin/go-proxy
|
||||||
|
|
||||||
|
archive:
|
||||||
|
git archive HEAD -o ../go-proxy-$$(date +"%Y%m%d%H%M").zip
|
||||||
|
|
||||||
|
repush:
|
||||||
|
git reset --soft HEAD^
|
||||||
|
git add -A
|
||||||
|
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)"
|
||||||
353
README.md
353
README.md
@@ -1,315 +1,110 @@
|
|||||||
# go-proxy
|
# go-proxy
|
||||||
|
|
||||||
A simple auto docker reverse proxy for home use. **Written in _Go_**
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://discord.gg/umReR62nRd)
|
||||||
|
|
||||||
In the examples domain `x.y.z` is used, replace them with your domain
|
[繁體中文文檔請看此](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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_Join our [Discord](https://discord.gg/umReR62nRd) for help and discussions_
|
||||||
|
|
||||||
## Table of content
|
## Table of content
|
||||||
|
|
||||||
- [Key Points](#key-points)
|
<!-- TOC -->
|
||||||
- [How to use](#how-to-use)
|
|
||||||
- [Binary](#binary)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [Configuration](#configuration)
|
|
||||||
- [Single Port Configuration](#single-port-configuration-example)
|
|
||||||
- [Multiple Ports Configuration](#multiple-ports-configuration-example)
|
|
||||||
- [TCP/UDP Configuration](#tcpudp-configuration-example)
|
|
||||||
- [Load balancing Configuration](#load-balancing-configuration-example)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
- [Benchmarks](#benchmarks)
|
|
||||||
- [Memory usage](#memory-usage)
|
|
||||||
- [Build it yourself](#build-it-yourself)
|
|
||||||
- [Getting SSL certs](#getting-ssl-certs)
|
|
||||||
|
|
||||||
## Key Points
|
- [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)
|
||||||
|
|
||||||
- fast, nearly no performance penalty for end users when comparing to direct IP connections (See [benchmarks](#benchmarks))
|
## Key Features
|
||||||
- auto detect reverse proxies from docker
|
|
||||||
- additional reverse proxies from provider yaml file
|
|
||||||
- allow multiple docker / file providers by custom `config.yml` file
|
|
||||||
- subdomain matching **(domain name doesn't matter)**
|
|
||||||
- path matching
|
|
||||||
- HTTP proxy
|
|
||||||
- TCP/UDP Proxy
|
|
||||||
- HTTP round robin load balance support (same subdomain and path across different hosts)
|
|
||||||
- Auto hot-reload on container start / die / stop or config changes.
|
|
||||||
- Simple panel to see all reverse proxies and health (visit port [panel port] of go-proxy `https://*.y.z:[panel port]`)
|
|
||||||
|
|
||||||

|
- 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)**
|
||||||
|
|
||||||
## How to use
|
[🔼Back to top](#table-of-content)
|
||||||
|
|
||||||
1. Download and extract the latest release (or clone the repository if you want to try out experimental features)
|
## Getting Started
|
||||||
|
|
||||||
2. Copy `config.example.yml` to `config.yml` and modify the content to fit your needs
|
### Setup
|
||||||
|
|
||||||
3. Do the same for `providers.example.yml`
|
1. Pull docker image
|
||||||
|
|
||||||
|
```shell
|
||||||
|
docker pull ghcr.io/yusing/go-proxy:latest
|
||||||
|
```
|
||||||
|
|
||||||
4. See [Binary](#binary) or [docker](#docker)
|
2. Create new directory, `cd` into it, then run setup
|
||||||
|
|
||||||
### Binary
|
```shell
|
||||||
|
docker run --rm -v .:/setup ghcr.io/yusing/go-proxy /app/go-proxy setup
|
||||||
|
```
|
||||||
|
|
||||||
1. (Optional) Prepare your certificates in `certs/` to enable https. See [Getting SSL Certs](#getting-ssl-certs)
|
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`
|
||||||
|
|
||||||
- cert / chain / fullchain: ./certs/cert.crt
|
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`
|
||||||
- private key: ./certs/priv.key
|
|
||||||
|
|
||||||
2. run the binary `bin/go-proxy`
|
5. Done. 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. enjoy
|
[🔼Back to top](#table-of-content)
|
||||||
|
|
|
||||||
|
|
||||||
### Docker
|
### Use JSON Schema in VSCode
|
||||||
|
|
||||||
1. Copy content from [compose.example.yml](compose.example.yml) and create your own `compose.yml`
|
Copy [`.vscode/settings.example.json`](.vscode/settings.example.json) to `.vscode/settings.json` and modify it to fit your needs
|
||||||
|
|
||||||
2. Add networks to make sure it is in the same network with other containers, or make sure `proxy.<alias>.host` is reachable
|
[🔼Back to top](#table-of-content)
|
||||||
|
|
||||||
3. (Optional) Mount your SSL certs to enable https. See [Getting SSL Certs](#getting-ssl-certs)
|
## Screenshots
|
||||||
|
|
||||||
|
### idlesleeper
|
||||||
|
|
||||||
- cert / chain / fullchain -> /app/certs/cert.crt
|

|
||||||
- private key -> /app/certs/priv.key
|
|
||||||
|
|
||||||
4. Start `go-proxy` with `docker compose up -d` or `make up`.
|
|
||||||
|
|
||||||
5. (Optional) If you are using ufw with vpn that drop all inbound traffic except vpn, run below to allow docker containers to connect to `go-proxy`
|
[🔼Back to top](#table-of-content)
|
||||||
|
|
||||||
|
|
||||||
In case the network of your container is in subnet `172.16.0.0/16` (bridge),
|
|
||||||
and vpn network is under `100.64.0.0/10` (i.e. tailscale)
|
|
||||||
|
|
||||||
`sudo ufw allow from 172.16.0.0/16 to 100.64.0.0/10`
|
|
||||||
|
|
||||||
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' -`
|
|
||||||
|
|
||||||
6. start your docker app, and visit <container_name>.y.z
|
|
||||||
|
|
||||||
7. check the logs with `docker compose logs` or `make logs` to see if there is any error, check panel at [panel port] for active proxies
|
|
||||||
|
|
||||||
## Known issues
|
|
||||||
|
|
||||||
None
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
With container name, no label needs to be added.
|
|
||||||
|
|
||||||
However, there are some labels you can manipulate with:
|
|
||||||
|
|
||||||
- `proxy.aliases`: comma separated aliases for subdomain matching
|
|
||||||
- defaults to `container_name`
|
|
||||||
- `proxy.*.<field>`: wildcard config for all aliases
|
|
||||||
- `proxy.<alias>.scheme`: container port protocol (`http` or `https`)
|
|
||||||
- defaults to `http`
|
|
||||||
- `proxy.<alias>.host`: proxy host
|
|
||||||
- defaults to `container_name`
|
|
||||||
- `proxy.<alias>.port`: proxy port
|
|
||||||
- http/https: defaults to first expose port (declared in `Dockerfile` or `docker-compose.yml`)
|
|
||||||
- tcp/udp: is in format of `[<listeningPort>:]<targetPort>`
|
|
||||||
- when `listeningPort` is omitted (not suggested), a free port will be used automatically.
|
|
||||||
- `targetPort` must be a number, or the predefined names (see [stream.go](src/go-proxy/stream.go#L28))
|
|
||||||
- `no_tls_verify`: whether skip tls verify when scheme is https
|
|
||||||
- defaults to false
|
|
||||||
- `proxy.<alias>.path`: path matching (for http proxy only)
|
|
||||||
- defaults to empty
|
|
||||||
- `proxy.<alias>.path_mode`: mode for path handling
|
|
||||||
|
|
||||||
- defaults to 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="/path/to/file"` -> `href="/app1/path/to/file"`
|
|
||||||
|
|
||||||
- `proxy.<alias>.load_balance`: enable load balance
|
|
||||||
- allowed: `1`, `true`
|
|
||||||
|
|
||||||
### Single port configuration example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# (default) https://<container_name>.y.z
|
|
||||||
whoami:
|
|
||||||
image: traefik/whoami
|
|
||||||
container_name: whoami # => whoami.y.z
|
|
||||||
|
|
||||||
# enable both subdomain and path matching:
|
|
||||||
whoami:
|
|
||||||
image: traefik/whoami
|
|
||||||
container_name: whoami
|
|
||||||
labels:
|
|
||||||
- proxy.aliases=whoami,apps
|
|
||||||
- proxy.apps.path=/whoami
|
|
||||||
# 1. visit https://whoami.y.z
|
|
||||||
# 2. visit https://apps.y.z/whoami
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multiple ports configuration example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
minio:
|
|
||||||
image: quay.io/minio/minio
|
|
||||||
container_name: minio
|
|
||||||
...
|
|
||||||
labels:
|
|
||||||
- proxy.aliases=minio,minio-console
|
|
||||||
- proxy.minio.port=9000
|
|
||||||
- proxy.minio-console.port=9001
|
|
||||||
|
|
||||||
# visit https://minio.y.z to access minio
|
|
||||||
# visit https://minio-console.y.z/whoami to access minio console
|
|
||||||
```
|
|
||||||
|
|
||||||
### TCP/UDP configuration example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# In the app
|
|
||||||
app-db:
|
|
||||||
image: postgres:15
|
|
||||||
container_name: app-db
|
|
||||||
...
|
|
||||||
labels:
|
|
||||||
# Optional (postgres is in the known image map)
|
|
||||||
- proxy.app-db.scheme=tcp
|
|
||||||
|
|
||||||
# Optional (first free port will be used for listening port)
|
|
||||||
- proxy.app-db.port=20000:postgres
|
|
||||||
|
|
||||||
# In go-proxy
|
|
||||||
go-proxy:
|
|
||||||
...
|
|
||||||
ports:
|
|
||||||
- 80:80
|
|
||||||
...
|
|
||||||
- 20000:20000/tcp
|
|
||||||
# or 20000-20010:20000-20010/tcp to declare large range at once
|
|
||||||
|
|
||||||
# access app-db via <*>.y.z:20000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Load balancing Configuration Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
nginx:
|
|
||||||
...
|
|
||||||
deploy:
|
|
||||||
mode: replicated
|
|
||||||
replicas: 3
|
|
||||||
labels:
|
|
||||||
- proxy.nginx.load_balance=1 # allowed: [1, true]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
## 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 30s --latency http://10.0.100.1/bench
|
|
||||||
Running 30s test @ http://10.0.100.1/bench
|
|
||||||
10 threads and 200 connections
|
|
||||||
Thread Stats Avg Stdev Max +/- Stdev
|
|
||||||
Latency 4.34ms 1.16ms 22.76ms 85.77%
|
|
||||||
Req/Sec 4.63k 435.14 5.47k 90.07%
|
|
||||||
Latency Distribution
|
|
||||||
50% 3.95ms
|
|
||||||
75% 4.71ms
|
|
||||||
90% 5.68ms
|
|
||||||
99% 8.61ms
|
|
||||||
1383812 requests in 30.02s, 166.28MB read
|
|
||||||
Requests/sec: 46100.87
|
|
||||||
Transfer/sec: 5.54MB
|
|
||||||
```
|
|
||||||
|
|
||||||
- With reverse proxy
|
|
||||||
|
|
||||||
```shell
|
|
||||||
root@yusing-pc:~# wrk -t 10 -c 200 -d 30s --latency http://bench.6uo.me/bench
|
|
||||||
Running 30s test @ http://bench.6uo.me/bench
|
|
||||||
10 threads and 200 connections
|
|
||||||
Thread Stats Avg Stdev Max +/- Stdev
|
|
||||||
Latency 4.50ms 1.44ms 27.53ms 86.48%
|
|
||||||
Req/Sec 4.48k 375.00 5.12k 84.73%
|
|
||||||
Latency Distribution
|
|
||||||
50% 4.09ms
|
|
||||||
75% 5.06ms
|
|
||||||
90% 6.03ms
|
|
||||||
99% 9.41ms
|
|
||||||
1338996 requests in 30.01s, 160.90MB read
|
|
||||||
Requests/sec: 44616.36
|
|
||||||
Transfer/sec: 5.36MB
|
|
||||||
```
|
|
||||||
|
|
||||||
Local benchmark (client running wrk and `go-proxy` server are under same proxmox host but different LXCs)
|
|
||||||
|
|
||||||
- Direct connection
|
|
||||||
|
|
||||||
```
|
|
||||||
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 reverse proxy
|
|
||||||
```
|
|
||||||
root@http-benchmark-client:~# wrk -t 10 -c 200 -d 10s --latency http://bench.6uo.me/bench
|
|
||||||
Running 10s test @ http://bench.6uo.me/bench
|
|
||||||
10 threads and 200 connections
|
|
||||||
Thread Stats Avg Stdev Max +/- Stdev
|
|
||||||
Latency 1.78ms 5.49ms 117.53ms 99.00%
|
|
||||||
Req/Sec 16.31k 2.30k 21.01k 86.69%
|
|
||||||
Latency Distribution
|
|
||||||
50% 1.12ms
|
|
||||||
75% 1.88ms
|
|
||||||
90% 2.80ms
|
|
||||||
99% 7.27ms
|
|
||||||
1634774 requests in 10.10s, 196.44MB read
|
|
||||||
Requests/sec: 161858.70
|
|
||||||
Transfer/sec: 19.45MB
|
|
||||||
```
|
|
||||||
|
|
||||||
## Memory usage
|
|
||||||
|
|
||||||
It takes ~ 0.1-0.4MB for each HTTP Proxy, and <2MB for each TCP/UDP Proxy
|
|
||||||
|
|
||||||
## Build it yourself
|
## Build it yourself
|
||||||
|
|
||||||
1. Install [go](https://go.dev/doc/install) and `make` if not already
|
1. Clone the repository `git clone https://github.com/yusing/go-proxy --depth=1`
|
||||||
|
|
||||||
2. get dependencies with `make get`
|
2. Install / Upgrade [go (>=1.22)](https://go.dev/doc/install) and `make` if not already
|
||||||
|
|
||||||
3. build binary with `make build`
|
3. Clear cache if you have built this before (go < 1.22) with `go clean -cache`
|
||||||
|
|
||||||
4. start your container with `docker compose up -d`
|
4. get dependencies with `make get`
|
||||||
|
|
||||||
## Getting SSL certs
|
5. build binary with `make build`
|
||||||
|
|
||||||
I personally use `nginx-proxy-manager` to get SSL certs with auto renewal by Cloudflare DNS challenge. You may symlink the certs from `nginx-proxy-manager` to somewhere else, and mount them to `go-proxy`'s `/certs`
|
[🔼Back to top](#table-of-content)
|
||||||
|
|
||||||
[panel port]: 8443
|
|
||||||
|
|||||||
130
README_CHT.md
Normal file
130
README_CHT.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
# go-proxy
|
||||||
|
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=yusing_go-proxy)
|
||||||
|
[](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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[🔼 返回頂部](#目錄)
|
||||||
|
|
||||||
|
## 源碼編譯
|
||||||
|
|
||||||
|
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` 編譯
|
||||||
|
|
||||||
|
[🔼 返回頂部](#目錄)
|
||||||
BIN
bin/go-proxy
BIN
bin/go-proxy
Binary file not shown.
230
cmd/main.go
Executable file
230
cmd/main.go
Executable file
@@ -0,0 +1,230 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"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())
|
||||||
|
if err := autocert.Setup(ctx); err != nil {
|
||||||
|
l.Fatal(err)
|
||||||
|
} else {
|
||||||
|
onShutdown.Add(cancel)
|
||||||
|
}
|
||||||
|
} 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)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(onShutdown.Size())
|
||||||
|
onShutdown.ForEach(func(f func()) {
|
||||||
|
go func() {
|
||||||
|
l.Debugf("waiting for %s to complete...", funcName(f))
|
||||||
|
f()
|
||||||
|
l.Debugf("%s done", funcName(f))
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
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")
|
||||||
|
onShutdown.ForEach(func(f func()) {
|
||||||
|
l.Warnf("%s() is still running", funcName(f))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -1,52 +1,45 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
app:
|
frontend:
|
||||||
build: .
|
image: ghcr.io/yusing/go-proxy-frontend:latest
|
||||||
container_name: go-proxy
|
container_name: go-proxy-frontend
|
||||||
restart: always
|
restart: unless-stopped
|
||||||
networks: # ^also add here
|
network_mode: host
|
||||||
- default
|
depends_on:
|
||||||
# environment:
|
- app
|
||||||
# - GOPROXY_DEBUG=1 # (optional, enable only for debug)
|
# if you also want to proxy the WebUI and access it via gp.y.z
|
||||||
# - GOPROXY_REDIRECT_HTTP=0 # (optional, uncomment to disable http redirect (http -> https))
|
# labels:
|
||||||
ports:
|
# - proxy.aliases=gp
|
||||||
- 80:80 # http
|
# - proxy.gp.port=3000
|
||||||
# - 443:443 # optional, https
|
|
||||||
- 8080:8080 # http panel
|
|
||||||
# - 8443:8443 # optional, https panel
|
|
||||||
|
|
||||||
# optional, if you declared any tcp/udp proxy, set a range you want to use
|
# Make sure the value is same as `GOPROXY_API_ADDR` below (if you have changed it)
|
||||||
# - 20000:20100/tcp
|
#
|
||||||
# - 20000:20100/udp
|
# environment:
|
||||||
volumes:
|
# NEXT_PUBLIC_GOPROXY_API_ADDR: 127.0.0.1:8888
|
||||||
# if you want https
|
app:
|
||||||
# - /path/to/cert.pem:/app/certs/cert.crt:ro
|
image: ghcr.io/yusing/go-proxy:latest
|
||||||
# - /path/to/privkey.pem:/app/certs/priv.key:ro
|
container_name: go-proxy
|
||||||
|
restart: always
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
# (Optional) change this to your timezone to get correct log timestamp
|
||||||
|
TZ: ETC/UTC
|
||||||
|
|
||||||
# path to logs
|
# Change these if you need
|
||||||
- ./log:/app/log
|
#
|
||||||
|
# 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 you use default config, or declared local docker provider
|
# (Optional) choose one of below to enable https
|
||||||
# otherwise comment this line
|
# 1. use existing certificate
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
# 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`
|
||||||
|
|
||||||
# to use custom config
|
# - /path/to/certs:/app/certs
|
||||||
# - path/to/config.yml:/app/config.yml
|
|
||||||
|
|
||||||
# mount file provider yaml files
|
# 2. use autocert, certs will be stored in ./certs (or other path you specify)
|
||||||
# - path/to/provider1.yml:/app/provider1.yml
|
|
||||||
# - path/to/provider2.yml:/app/provider2.yml
|
# - ./certs:/app/certs
|
||||||
# etc.
|
|
||||||
dns:
|
|
||||||
- 127.0.0.1 # workaround for "lookup: no such host"
|
|
||||||
extra_hosts:
|
|
||||||
# required if you use local docker provider and have containers in `host` network_mode
|
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
providers:
|
|
||||||
local:
|
|
||||||
kind: docker
|
|
||||||
# for value format, see https://docs.docker.com/reference/cli/dockerd/
|
|
||||||
value: FROM_ENV
|
|
||||||
remote1:
|
|
||||||
kind: docker
|
|
||||||
value: ssh://user@10.0.1.1
|
|
||||||
remote2:
|
|
||||||
kind: docker
|
|
||||||
value: tcp://10.0.1.1:2375
|
|
||||||
# provider1:
|
|
||||||
# kind: file
|
|
||||||
# value: provider1.yml
|
|
||||||
# provider2:
|
|
||||||
# kind: file
|
|
||||||
# value: provider2.yml
|
|
||||||
69
config.example.yml
Normal file
69
config.example.yml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Autocert (choose one below and uncomment to enable)
|
||||||
|
#
|
||||||
|
# 1. use existing cert
|
||||||
|
#
|
||||||
|
# autocert:
|
||||||
|
# provider: local
|
||||||
|
#
|
||||||
|
# cert_path: certs/cert.crt # optional, uncomment only if you need to change it
|
||||||
|
# key_path: certs/priv.key # optional, uncomment only if you need to change it
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Below are fixed options (non hot-reloadable)
|
||||||
|
|
||||||
|
# timeout for shutdown (in seconds)
|
||||||
|
#
|
||||||
|
# timeout_shutdown: 5
|
||||||
|
|
||||||
|
# global setting redirect http requests to https (if https available, otherwise this will be ignored)
|
||||||
|
# proxy.<alias>.middlewares.redirect_http will override this
|
||||||
|
#
|
||||||
|
# redirect_to_https: false
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
if [ "$1" == "restart" ]; then
|
|
||||||
echo "restarting"
|
|
||||||
killall go-proxy
|
|
||||||
fi
|
|
||||||
if [ "$DEBUG" == "1" ]; then
|
|
||||||
/app/go-proxy 2> log/go-proxy.log &
|
|
||||||
tail -f /dev/null
|
|
||||||
else
|
|
||||||
/app/go-proxy
|
|
||||||
fi
|
|
||||||
16
examples/microbin.yml
Normal file
16
examples/microbin.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
container_name: microbin
|
||||||
|
cpu_shares: 10
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
env_file: .env
|
||||||
|
image: docker.i.sh/danielszabo99/microbin:latest
|
||||||
|
ports:
|
||||||
|
- 8080
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/microbin_data
|
||||||
|
# microbin.domain.tld
|
||||||
16
examples/siyuan.yml
Normal file
16
examples/siyuan.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
54
go.mod
Executable file → Normal file
54
go.mod
Executable file → Normal file
@@ -1,41 +1,59 @@
|
|||||||
module github.com/yusing/go-proxy
|
module github.com/yusing/go-proxy
|
||||||
|
|
||||||
go 1.21.7
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/docker/cli v25.0.4+incompatible
|
github.com/coder/websocket v1.8.12
|
||||||
github.com/docker/docker v25.0.4+incompatible
|
github.com/docker/cli v27.3.1+incompatible
|
||||||
|
github.com/docker/docker v27.3.1+incompatible
|
||||||
github.com/fsnotify/fsnotify v1.7.0
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
|
github.com/go-acme/lego/v4 v4.19.0
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.4.0
|
||||||
|
github.com/santhosh-tekuri/jsonschema v1.2.4
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
golang.org/x/net v0.22.0
|
golang.org/x/net v0.30.0
|
||||||
|
golang.org/x/text v0.19.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cloudflare/cloudflare-go v0.106.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/distribution/reference v0.5.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/go-connections v0.5.0 // indirect
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.1 // indirect
|
github.com/go-jose/go-jose/v4 v4.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
github.com/go-logr/stdr v1.2.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/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
github.com/miekg/dns v1.1.62 // indirect
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
github.com/moby/term v0.5.0 // indirect
|
github.com/moby/term v0.5.0 // indirect
|
||||||
github.com/morikuni/aec v1.0.0 // indirect
|
github.com/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.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/pkg/errors v0.9.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
|
go.opentelemetry.io/otel v1.30.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.30.0 // indirect
|
||||||
golang.org/x/mod v0.16.0 // indirect
|
go.opentelemetry.io/otel/trace v1.30.0 // indirect
|
||||||
golang.org/x/sys v0.18.0 // indirect
|
golang.org/x/crypto v0.28.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/mod v0.21.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/oauth2 v0.23.0 // indirect
|
||||||
golang.org/x/tools v0.19.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
|
||||||
gotest.tools/v3 v3.5.1 // indirect
|
gotest.tools/v3 v3.5.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
164
go.sum
Executable file → Normal file
164
go.sum
Executable file → Normal file
@@ -1,20 +1,26 @@
|
|||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cloudflare/cloudflare-go v0.106.0 h1:q41gC5Wc1nfi0D1ZhSHokWcd9mGMbqC7RE7qiP+qE00=
|
||||||
|
github.com/cloudflare/cloudflare-go v0.106.0/go.mod h1:pfUQ4PIG4ISI0/Mmc21Bp86UnFU0ktmPf3iTgbSL+cM=
|
||||||
|
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
|
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/docker/cli v25.0.4+incompatible h1:DatRkJ+nrFoYL2HZUzjM5Z5sAmcA5XGp+AW0oEw2+cA=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/docker/cli v25.0.4+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v25.0.4+incompatible h1:XITZTrq+52tZyZxUOtFIahUf3aH367FLxJzt9vZeAF8=
|
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
|
||||||
github.com/docker/docker v25.0.4+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
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/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
@@ -23,21 +29,42 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
|||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/go-acme/lego/v4 v4.19.0 h1:c7YabBOwoa2URsPiCNGQsdzQnbd8Y23B4/66gxh4H7c=
|
||||||
|
github.com/go-acme/lego/v4 v4.19.0/go.mod h1:wtDe3dDkmV4/oI2nydpNXSJpvV10J9RCyZ6MbYxNtlQ=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E=
|
||||||
|
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
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-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No=
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU=
|
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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/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/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/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=
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
@@ -46,84 +73,103 @@ 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/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 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.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/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=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 h1:ZIg3ZT/aQ7AfKqdwp7ECpOK6vHqquXXuyTjIO8ZdmPs=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0/go.mod h1:DQAwmETtZV00skUwgD6+0U89g80NKsJE3DCKeLLPQMI=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
|
||||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0 h1:lsInsfvhVIfOI6qHVyysXMNDnjO9Npvl7tlDPJFBVd4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
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.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
|
||||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
|
||||||
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
|
go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
|
||||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
|
||||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
|
||||||
go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI=
|
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||||
go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY=
|
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
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.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
||||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
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-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-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.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||||
|
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||||
|
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.8.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-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-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-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.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-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-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-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.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||||
google.golang.org/grpc v1.61.1 h1:kLAiWrZs7YeDM6MumDe7m3y4aM6wacLzM1Y/wiLP9XY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
google.golang.org/grpc v1.61.1/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
|
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
|
||||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
63
internal/api/handler.go
Normal file
63
internal/api/handler.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||||
|
"github.com/yusing/go-proxy/internal/api/v1/error_page"
|
||||||
|
. "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
"github.com/yusing/go-proxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServeMux struct{ *http.ServeMux }
|
||||||
|
|
||||||
|
func NewServeMux() ServeMux {
|
||||||
|
return ServeMux{http.NewServeMux()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mux ServeMux) HandleFunc(method, endpoint string, handler http.HandlerFunc) {
|
||||||
|
mux.ServeMux.HandleFunc(fmt.Sprintf("%s %s", method, endpoint), checkHost(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(cfg *config.Config) http.Handler {
|
||||||
|
mux := NewServeMux()
|
||||||
|
mux.HandleFunc("GET", "/v1", v1.Index)
|
||||||
|
mux.HandleFunc("GET", "/v1/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", error_page.GetHandleFunc())
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow only requests to API server with host matching common.APIHTTPAddr
|
||||||
|
func checkHost(f http.HandlerFunc) http.HandlerFunc {
|
||||||
|
if common.IsDebug {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Host != common.APIHTTPAddr {
|
||||||
|
Logger.Warnf("invalid request to API server with host: %s, expect %s", r.Host, common.APIHTTPAddr)
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("invalid request"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrap(cfg *config.Config, f func(cfg *config.Config, w http.ResponseWriter, r *http.Request)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f(cfg, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/api/v1/checkhealth.go
Normal file
42
internal/api/v1/checkhealth.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/config"
|
||||||
|
R "github.com/yusing/go-proxy/internal/route"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckHealth(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
target := r.FormValue("target")
|
||||||
|
if target == "" {
|
||||||
|
U.HandleErr(w, r, U.ErrMissingKey("target"), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
route := cfg.FindRoute(target)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case route == nil:
|
||||||
|
U.HandleErr(w, r, U.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
88
internal/api/v1/error_page/error_page.go
Normal file
88
internal/api/v1/error_page/error_page.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package error_page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
api "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||||
|
W "github.com/yusing/go-proxy/internal/watcher"
|
||||||
|
"github.com/yusing/go-proxy/internal/watcher/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
const errPagesBasePath = common.ErrorPagesBasePath
|
||||||
|
|
||||||
|
var setup = sync.OnceFunc(func() {
|
||||||
|
dirWatcher = W.NewDirectoryWatcher(context.Background(), errPagesBasePath)
|
||||||
|
loadContent()
|
||||||
|
go watchDir()
|
||||||
|
})
|
||||||
|
|
||||||
|
func GetStaticFile(filename string) ([]byte, bool) {
|
||||||
|
return fileContentMap.Load(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// try <statusCode>.html -> 404.html -> not ok
|
||||||
|
func GetErrorPageByStatus(statusCode int) (content []byte, ok bool) {
|
||||||
|
content, ok = fileContentMap.Load(fmt.Sprintf("%d.html", statusCode))
|
||||||
|
if !ok && statusCode != 404 {
|
||||||
|
return fileContentMap.Load("404.html")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadContent() {
|
||||||
|
files, err := U.ListFiles(errPagesBasePath, 0)
|
||||||
|
if err != nil {
|
||||||
|
api.Logger.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
if fileContentMap.Has(file) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
api.Logger.Errorf("failed to read error page resource %s: %s", file, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file = path.Base(file)
|
||||||
|
api.Logger.Infof("error page resource %s loaded", file)
|
||||||
|
fileContentMap.Store(file, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func watchDir() {
|
||||||
|
eventCh, errCh := dirWatcher.Events(context.Background())
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event, ok := <-eventCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := event.ActorName
|
||||||
|
switch event.Action {
|
||||||
|
case events.ActionFileWritten:
|
||||||
|
fileContentMap.Delete(filename)
|
||||||
|
loadContent()
|
||||||
|
case events.ActionFileDeleted:
|
||||||
|
fileContentMap.Delete(filename)
|
||||||
|
api.Logger.Infof("error page resource %s deleted", filename)
|
||||||
|
case events.ActionFileRenamed:
|
||||||
|
api.Logger.Infof("error page resource %s deleted", filename)
|
||||||
|
fileContentMap.Delete(filename)
|
||||||
|
loadContent()
|
||||||
|
}
|
||||||
|
case err := <-errCh:
|
||||||
|
api.Logger.Errorf("error watching error page directory: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirWatcher W.Watcher
|
||||||
|
var fileContentMap = F.NewMapOf[string, []byte]()
|
||||||
25
internal/api/v1/error_page/http_handler.go
Normal file
25
internal/api/v1/error_page/http_handler.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package error_page
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func GetHandleFunc() http.HandlerFunc {
|
||||||
|
setup()
|
||||||
|
return serveHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
http.Error(w, "invalid path", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content, ok := fileContentMap.Load(r.URL.Path)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "404 not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(content)
|
||||||
|
}
|
||||||
60
internal/api/v1/file.go
Normal file
60
internal/api/v1/file.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
w.Write(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, validateErr.JSONObject(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(path.Join(common.ConfigBasePath, filename), content, 0644)
|
||||||
|
if err != nil {
|
||||||
|
U.HandleErr(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
34
internal/api/v1/health_check.go
Normal file
34
internal/api/v1/health_check.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
7
internal/api/v1/index.go
Normal file
7
internal/api/v1/index.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func Index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("API ready"))
|
||||||
|
}
|
||||||
87
internal/api/v1/list.go
Normal file
87
internal/api/v1/list.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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.HandleErr(w, r, U.RespondJson(w, 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.HandleErr(w, r, U.RespondJson(w, files))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMiddlewareTrace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
U.HandleErr(w, r, U.RespondJson(w, middleware.GetAllTrace()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMiddlewares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
U.HandleErr(w, r, U.RespondJson(w, middleware.All()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listMatchDomains(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
U.HandleErr(w, r, U.RespondJson(w, cfg.Value().MatchDomains))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listHomepageConfig(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
U.HandleErr(w, r, U.RespondJson(w, cfg.HomepageConfig()))
|
||||||
|
}
|
||||||
69
internal/api/v1/query/query.go
Normal file
69
internal/api/v1/query/query.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
v1 "github.com/yusing/go-proxy/internal/api/v1"
|
||||||
|
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
"github.com/yusing/go-proxy/internal/net/http/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReloadServer() E.NestedError {
|
||||||
|
resp, err := U.Post(fmt.Sprintf("%s/v1/reload", common.APIHTTPURL), "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return E.From(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
failure := E.Failure("server reload").Extraf("status code: %v", resp.StatusCode)
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return failure.Extraf("unable to read response body: %s", err)
|
||||||
|
}
|
||||||
|
reloadErr, ok := E.FromJSON(b)
|
||||||
|
if ok {
|
||||||
|
return E.Join("reload success, but server returned error", reloadErr)
|
||||||
|
}
|
||||||
|
return failure.Extraf("unable to read response body")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListRoutes() (map[string]map[string]any, E.NestedError) {
|
||||||
|
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, v1.ListRoutes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, E.Failure("list routes").Extraf("status code: %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var routes map[string]map[string]any
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&routes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
return routes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListMiddlewareTraces() (middleware.Traces, E.NestedError) {
|
||||||
|
resp, err := U.Get(fmt.Sprintf("%s/v1/list/%s", common.APIHTTPURL, v1.ListMiddlewareTrace))
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, E.Failure("list middleware trace").Extraf("status code: %v", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var traces middleware.Traces
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&traces)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
return traces, nil
|
||||||
|
}
|
||||||
16
internal/api/v1/reload.go
Normal file
16
internal/api/v1/reload.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Reload(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := cfg.Reload(); err != nil {
|
||||||
|
U.RespondJson(w, err.JSONObject(), http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/api/v1/stats.go
Normal file
67
internal/api/v1/stats.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
U "github.com/yusing/go-proxy/internal/api/v1/utils"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
"github.com/yusing/go-proxy/internal/config"
|
||||||
|
"github.com/yusing/go-proxy/internal/server"
|
||||||
|
"github.com/yusing/go-proxy/internal/utils"
|
||||||
|
|
||||||
|
"github.com/coder/websocket"
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Stats(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
U.HandleErr(w, r, U.RespondJson(w, getStats(cfg)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func StatsWS(cfg *config.Config, w http.ResponseWriter, r *http.Request) {
|
||||||
|
localAddresses := []string{"127.0.0.1", "10.0.*.*", "172.16.*.*", "192.168.*.*"}
|
||||||
|
originPats := make([]string, len(cfg.Value().MatchDomains)+len(localAddresses))
|
||||||
|
|
||||||
|
if len(originPats) == 0 {
|
||||||
|
U.Logger.Warnf("no match domains configured, accepting websocket request from all origins")
|
||||||
|
originPats = []string{"*"}
|
||||||
|
} else {
|
||||||
|
for i, domain := range cfg.Value().MatchDomains {
|
||||||
|
originPats[i] = "*." + domain
|
||||||
|
}
|
||||||
|
originPats = append(originPats, localAddresses...)
|
||||||
|
}
|
||||||
|
if common.IsDebug {
|
||||||
|
originPats = []string{"*"}
|
||||||
|
}
|
||||||
|
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||||
|
OriginPatterns: originPats,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
U.Logger.Errorf("/stats/ws failed to upgrade websocket: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.CloseNow()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
stats := getStats(cfg)
|
||||||
|
if err := wsjson.Write(ctx, conn, stats); err != nil {
|
||||||
|
U.Logger.Errorf("/stats/ws failed to write JSON: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStats(cfg *config.Config) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"proxies": cfg.Statistics(),
|
||||||
|
"uptime": utils.FormatDuration(server.GetProxyServer().Uptime()),
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/api/v1/utils/error.go
Normal file
37
internal/api/v1/utils/error.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
27
internal/api/v1/utils/http_client.go
Normal file
27
internal/api/v1/utils/http_client.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var Get = HTTPClient.Get
|
||||||
|
var Post = HTTPClient.Post
|
||||||
|
var Head = HTTPClient.Head
|
||||||
20
internal/api/v1/utils/utils.go
Normal file
20
internal/api/v1/utils/utils.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RespondJson(w http.ResponseWriter, data any, code ...int) error {
|
||||||
|
if len(code) > 0 {
|
||||||
|
w.WriteHeader(code[0])
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
j, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
w.Write(j)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
internal/api/v1/version.go
Normal file
11
internal/api/v1/version.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/pkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(pkg.GetVersion()))
|
||||||
|
}
|
||||||
76
internal/autocert/config.go
Normal file
76
internal/autocert/config.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
40
internal/autocert/constants.go
Normal file
40
internal/autocert/constants.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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")
|
||||||
20
internal/autocert/dummy.go
Normal file
20
internal/autocert/dummy.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
296
internal/autocert/provider.go
Normal file
296
internal/autocert/provider.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProviderGenerator func(types.AutocertProviderOpt) (challenge.Provider, E.NestedError)
|
||||||
|
type 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
|
||||||
|
}
|
||||||
|
}
|
||||||
50
internal/autocert/provider_test/ovh_test.go
Normal file
50
internal/autocert/provider_test/ovh_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
29
internal/autocert/setup.go
Normal file
29
internal/autocert/setup.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
9
internal/autocert/state.go
Normal file
9
internal/autocert/state.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package autocert
|
||||||
|
|
||||||
|
type CertState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CertStateValid CertState = iota
|
||||||
|
CertStateExpired
|
||||||
|
CertStateMismatch
|
||||||
|
)
|
||||||
22
internal/autocert/user.go
Normal file
22
internal/autocert/user.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package autocert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-acme/lego/v4/registration"
|
||||||
|
"crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
57
internal/common/args.go
Normal file
57
internal/common/args.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
55
internal/common/constants.go
Normal file
55
internal/common/constants.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
66
internal/common/env.go
Normal file
66
internal/common/env.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
75
internal/common/ports.go
Normal file
75
internal/common/ports.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
236
internal/config/config.go
Normal file
236
internal/config/config.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
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(dockerHost))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) controlProviders(action string, do func(*PR.Provider) E.NestedError) {
|
||||||
|
errors := E.NewBuilder("errors in %s these providers", action)
|
||||||
|
|
||||||
|
cfg.proxyProviders.RangeAll(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)
|
||||||
|
}
|
||||||
150
internal/config/query.go
Normal file
150
internal/config/query.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
153
internal/docker/client.go
Normal file
153
internal/docker/client.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseDockerHostname(host string) (string, E.NestedError) {
|
||||||
|
switch host {
|
||||||
|
case common.DockerHostFromEnv, "":
|
||||||
|
return "localhost", nil
|
||||||
|
}
|
||||||
|
url, err := E.Check(client.ParseHostURL(host))
|
||||||
|
if err != nil {
|
||||||
|
return "", E.Invalid("host", host).With(err)
|
||||||
|
}
|
||||||
|
return url.Hostname(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Client) DaemonHostname() string {
|
||||||
|
// DaemonHost should always return a valid host
|
||||||
|
hostname, _ := ParseDockerHostname(c.DaemonHost())
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.RangeAll(func(_ string, c Client) {
|
||||||
|
c.Client.Close()
|
||||||
|
})
|
||||||
|
clientMap.Clear()
|
||||||
|
logger.Debug("closed all clients")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
clientMap F.Map[string, Client] = F.NewMapOf[string, Client]()
|
||||||
|
clientMapMu sync.Mutex
|
||||||
|
|
||||||
|
clientOptEnvHost = []client.Opt{
|
||||||
|
client.WithHostFromEnv(),
|
||||||
|
client.WithAPIVersionNegotiation(),
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = logrus.WithField("module", "docker")
|
||||||
|
)
|
||||||
54
internal/docker/client_info.go
Normal file
54
internal/docker/client_info.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
143
internal/docker/container.go
Normal file
143
internal/docker/container.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Container struct {
|
||||||
|
*types.Container
|
||||||
|
*ProxyProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromDocker(c *types.Container, dockerHost string) (res Container) {
|
||||||
|
res.Container = c
|
||||||
|
isExplicit := c.Labels[LabelAliases] != ""
|
||||||
|
res.ProxyProperties = &ProxyProperties{
|
||||||
|
DockerHost: dockerHost,
|
||||||
|
ContainerName: res.getName(),
|
||||||
|
ContainerID: c.ID,
|
||||||
|
ImageName: res.getImageName(),
|
||||||
|
PublicPortMapping: res.getPublicPortMapping(),
|
||||||
|
PrivatePortMapping: res.getPrivatePortMapping(),
|
||||||
|
NetworkMode: c.HostConfig.NetworkMode,
|
||||||
|
Aliases: res.getAliases(),
|
||||||
|
IsExcluded: U.ParseBool(res.getDeleteLabel(LabelExclude)),
|
||||||
|
IsExplicit: isExplicit,
|
||||||
|
IsDatabase: res.isDatabase(),
|
||||||
|
IdleTimeout: res.getDeleteLabel(LabelIdleTimeout),
|
||||||
|
WakeTimeout: res.getDeleteLabel(LabelWakeTimeout),
|
||||||
|
StopMethod: res.getDeleteLabel(LabelStopMethod),
|
||||||
|
StopTimeout: res.getDeleteLabel(LabelStopTimeout),
|
||||||
|
StopSignal: res.getDeleteLabel(LabelStopSignal),
|
||||||
|
Running: c.Status == "running" || c.State == "running",
|
||||||
|
}
|
||||||
|
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) getDeleteLabel(label string) string {
|
||||||
|
if l, ok := c.Labels[label]; ok {
|
||||||
|
delete(c.Labels, label)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Container) getAliases() []string {
|
||||||
|
if l := c.getDeleteLabel(LabelAliases); l != "" {
|
||||||
|
return U.CommaSeperatedList(l)
|
||||||
|
} else {
|
||||||
|
return []string{c.getName()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Container) getName() string {
|
||||||
|
return strings.TrimPrefix(c.Names[0], "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Container) getImageName() string {
|
||||||
|
colonSep := strings.Split(c.Image, ":")
|
||||||
|
slashSep := strings.Split(colonSep[0], "/")
|
||||||
|
return slashSep[len(slashSep)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Container) getPublicPortMapping() PortMapping {
|
||||||
|
res := make(PortMapping)
|
||||||
|
for _, v := range c.Ports {
|
||||||
|
if v.PublicPort == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res[fmt.Sprint(v.PublicPort)] = v
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Container) getPrivatePortMapping() PortMapping {
|
||||||
|
res := make(PortMapping)
|
||||||
|
for _, v := range c.Ports {
|
||||||
|
res[fmt.Sprint(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 Container) isDatabase() bool {
|
||||||
|
for _, m := range c.Container.Mounts {
|
||||||
|
if _, ok := databaseMPs[m.Destination]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range c.Ports {
|
||||||
|
if _, ok := databasePrivPorts[v.PrivatePort]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
87
internal/docker/idlewatcher/html/loading_page.html
Normal file
87
internal/docker/idlewatcher/html/loading_page.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!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 result = await fetch(window.location.href, {
|
||||||
|
headers: {
|
||||||
|
{{ range $key, $value := .RequestHeaders }}
|
||||||
|
'{{ $key }}' : {{ $value }}
|
||||||
|
{{ end }}
|
||||||
|
},
|
||||||
|
}).then((resp) => resp.text())
|
||||||
|
.catch((err) => {
|
||||||
|
document.getElementById("message").innerText = err;
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
document.documentElement.innerHTML = result
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<div class="{{.SpinnerClass}}"></div>
|
||||||
|
<div class="message">{{.Message}}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
internal/docker/idlewatcher/http.go
Normal file
87
internal/docker/idlewatcher/http.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package idlewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateData struct {
|
||||||
|
Title string
|
||||||
|
Message string
|
||||||
|
RequestHeaders http.Header
|
||||||
|
SpinnerClass string
|
||||||
|
}
|
||||||
|
|
||||||
|
//go:embed html/loading_page.html
|
||||||
|
var loadingPage []byte
|
||||||
|
var loadingPageTmpl = template.Must(template.New("loading_page").Parse(string(loadingPage)))
|
||||||
|
|
||||||
|
const (
|
||||||
|
htmlContentType = "text/html; charset=utf-8"
|
||||||
|
|
||||||
|
errPrefix = "\u1000"
|
||||||
|
|
||||||
|
headerGoProxyTargetURL = "X-GoProxy-Target"
|
||||||
|
headerContentType = "Content-Type"
|
||||||
|
|
||||||
|
spinnerClassSpinner = "spinner"
|
||||||
|
spinnerClassErrorSign = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (w *watcher) makeSuccResp(redirectURL string, resp *http.Response) (*http.Response, error) {
|
||||||
|
h := make(http.Header)
|
||||||
|
h.Set("Location", redirectURL)
|
||||||
|
h.Set("Content-Length", "0")
|
||||||
|
h.Set(headerContentType, htmlContentType)
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusTemporaryRedirect,
|
||||||
|
Header: h,
|
||||||
|
Body: http.NoBody,
|
||||||
|
TLS: resp.TLS,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) makeErrResp(errFmt string, args ...any) (*http.Response, error) {
|
||||||
|
return w.makeResp(errPrefix+errFmt, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) makeResp(format string, args ...any) (*http.Response, error) {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
|
||||||
|
data := new(templateData)
|
||||||
|
data.Title = w.ContainerName
|
||||||
|
data.Message = strings.ReplaceAll(msg, "\n", "<br>")
|
||||||
|
data.Message = strings.ReplaceAll(data.Message, " ", " ")
|
||||||
|
data.RequestHeaders = make(http.Header)
|
||||||
|
data.RequestHeaders.Add(headerGoProxyTargetURL, "window.location.href")
|
||||||
|
if strings.HasPrefix(data.Message, errPrefix) {
|
||||||
|
data.Message = strings.TrimLeft(data.Message, errPrefix)
|
||||||
|
data.SpinnerClass = spinnerClassErrorSign
|
||||||
|
} else {
|
||||||
|
data.SpinnerClass = spinnerClassSpinner
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := bytes.NewBuffer(make([]byte, 128)) // more than enough
|
||||||
|
err := loadingPageTmpl.Execute(buf, data)
|
||||||
|
if err != nil { // should never happen
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusAccepted,
|
||||||
|
Header: http.Header{
|
||||||
|
headerContentType: {htmlContentType},
|
||||||
|
"Cache-Control": {
|
||||||
|
"no-cache",
|
||||||
|
"no-store",
|
||||||
|
"must-revalidate",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Body: io.NopCloser(buf),
|
||||||
|
ContentLength: int64(buf.Len()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
82
internal/docker/idlewatcher/round_trip.go
Normal file
82
internal/docker/idlewatcher/round_trip.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package idlewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
roundTripper struct {
|
||||||
|
patched roundTripFunc
|
||||||
|
}
|
||||||
|
roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return rt.patched(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) roundTrip(origRoundTrip roundTripFunc, req *http.Request) (*http.Response, error) {
|
||||||
|
// target site is ready, passthrough
|
||||||
|
if w.ready.Load() {
|
||||||
|
return origRoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial request
|
||||||
|
targetUrl := req.Header.Get(headerGoProxyTargetURL)
|
||||||
|
if targetUrl == "" {
|
||||||
|
return w.makeResp(
|
||||||
|
"%s is starting... Please wait",
|
||||||
|
w.ContainerName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.l.Debug("serving event")
|
||||||
|
|
||||||
|
// stream request
|
||||||
|
rtDone := make(chan *http.Response, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(req.Context(), w.WakeTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// loop original round trip until success in a goroutine
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// wake the container and reset idle timer
|
||||||
|
select {
|
||||||
|
case w.wakeCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
resp, err := origRoundTrip(req)
|
||||||
|
if err == nil {
|
||||||
|
w.ready.Store(true)
|
||||||
|
rtDone <- resp
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case resp := <-rtDone:
|
||||||
|
return w.makeSuccResp(targetUrl, resp)
|
||||||
|
case err := <-w.wakeDone:
|
||||||
|
if err != nil {
|
||||||
|
return w.makeErrResp("error waking up %s\n%s", w.ContainerName, err.Error())
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return w.makeErrResp("Timed out waiting for %s to fully wake", w.ContainerName)
|
||||||
|
}
|
||||||
|
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err())
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return w.makeErrResp("idlewatcher has stopped\n%s", w.ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
284
internal/docker/idlewatcher/watcher.go
Normal file
284
internal/docker/idlewatcher/watcher.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package idlewatcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"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"
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = make(map[string]*watcher)
|
||||||
|
watcherMapMu sync.Mutex
|
||||||
|
|
||||||
|
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 w, ok := watcherMap[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{}),
|
||||||
|
wakeDone: make(chan E.NestedError),
|
||||||
|
l: logger.WithField("container", entry.ContainerName),
|
||||||
|
}
|
||||||
|
w.refCount.Add(1)
|
||||||
|
w.stopByMethod = w.getStopCallback()
|
||||||
|
|
||||||
|
watcherMap[key] = w
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
newWatcherCh <- w
|
||||||
|
}()
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unregister(entry *P.ReverseProxyEntry) {
|
||||||
|
if w, ok := watcherMap[entry.ContainerID]; ok {
|
||||||
|
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
|
||||||
|
|
||||||
|
w.client.Close()
|
||||||
|
delete(watcherMap, w.ContainerID)
|
||||||
|
w.l.Debug("unregistered")
|
||||||
|
mainLoopWg.Done()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stop() {
|
||||||
|
mainLoopCancel()
|
||||||
|
mainLoopWg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) PatchRoundTripper(rtp http.RoundTripper) roundTripper {
|
||||||
|
return roundTripper{patched: func(r *http.Request) (*http.Response, error) {
|
||||||
|
return w.roundTrip(rtp.RoundTrip, r)
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *watcher) containerStop() error {
|
||||||
|
return w.client.ContainerStop(w.ctx, w.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) watchUntilCancel() {
|
||||||
|
defer close(w.wakeCh)
|
||||||
|
|
||||||
|
w.ctx, w.cancel = context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.IdleTimeout)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mainLoopCtx.Done():
|
||||||
|
w.cancel()
|
||||||
|
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
|
||||||
|
ticker.Reset(w.IdleTimeout)
|
||||||
|
w.l.Info(e)
|
||||||
|
default: // stop / pause / kill
|
||||||
|
w.ContainerRunning = false
|
||||||
|
ticker.Stop()
|
||||||
|
w.ready.Store(false)
|
||||||
|
w.l.Info(e)
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
w.l.Debug("idle timeout")
|
||||||
|
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")
|
||||||
|
ticker.Reset(w.IdleTimeout)
|
||||||
|
err := w.wakeIfStopped()
|
||||||
|
if err != nil && err.IsNot(context.Canceled) {
|
||||||
|
w.l.Error(E.FailWith("wake", err))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case w.wakeDone <- err: // this is passed to roundtrip
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
internal/docker/inspect.go
Normal file
19
internal/docker/inspect.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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 Container{}, E.From(err)
|
||||||
|
}
|
||||||
|
return FromJson(json, c.key), nil
|
||||||
|
}
|
||||||
115
internal/docker/label.go
Normal file
115
internal/docker/label.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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 := 0; i < reflect.TypeFor[T]().NumField(); i++ {
|
||||||
|
if objType.Field(i).Tag.Get("yaml") == l.Attribute {
|
||||||
|
field = reflect.ValueOf(obj).Elem().Field(i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !field.IsValid() {
|
||||||
|
return E.NotExist("field", l.Attribute)
|
||||||
|
}
|
||||||
|
dst, ok := field.Interface().(NestedLabelMap)
|
||||||
|
if !ok {
|
||||||
|
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
|
||||||
|
}
|
||||||
89
internal/docker/label_test.go
Normal file
89
internal/docker/label_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeLabel(ns, name, attr string) string {
|
||||||
|
return fmt.Sprintf("%s.%s.%s", ns, name, attr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedLabel(t *testing.T) {
|
||||||
|
mName := "middleware1"
|
||||||
|
mAttr := "prop1"
|
||||||
|
v := "value1"
|
||||||
|
pl, err := ParseLabel(makeLabel(NSProxy, "foo", 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"`
|
||||||
|
})
|
||||||
|
mName := "middleware1"
|
||||||
|
mAttr := "prop1"
|
||||||
|
v := "value1"
|
||||||
|
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) {
|
||||||
|
mName := "middleware1"
|
||||||
|
mAttr := "prop1"
|
||||||
|
v := "value1"
|
||||||
|
|
||||||
|
checkAttr := "prop2"
|
||||||
|
checkV := "value2"
|
||||||
|
entry := new(struct {
|
||||||
|
Middlewares NestedLabelMap `yaml:"middlewares"`
|
||||||
|
})
|
||||||
|
entry.Middlewares = make(NestedLabelMap)
|
||||||
|
entry.Middlewares[mName] = make(U.SerializedObject)
|
||||||
|
entry.Middlewares[mName][checkAttr] = checkV
|
||||||
|
|
||||||
|
pl, err := ParseLabel(makeLabel(NSProxy, "foo", 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) {
|
||||||
|
mName := "middleware1"
|
||||||
|
v := "value1"
|
||||||
|
|
||||||
|
entry := new(struct {
|
||||||
|
Middlewares NestedLabelMap `yaml:"middlewares"`
|
||||||
|
})
|
||||||
|
entry.Middlewares = make(NestedLabelMap)
|
||||||
|
entry.Middlewares[mName] = make(U.SerializedObject)
|
||||||
|
|
||||||
|
pl, err := ParseLabel(makeLabel(NSProxy, "foo", fmt.Sprintf("%s.%s", "middlewares", mName)), v)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
err = ApplyLabel(entry, pl)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
_, ok := entry.Middlewares[mName]
|
||||||
|
ExpectTrue(t, ok)
|
||||||
|
}
|
||||||
16
internal/docker/labels.go
Normal file
16
internal/docker/labels.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
25
internal/docker/proxy_properties.go
Normal file
25
internal/docker/proxy_properties.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import "github.com/docker/docker/api/types"
|
||||||
|
|
||||||
|
type PortMapping = map[string]types.Port
|
||||||
|
type ProxyProperties struct {
|
||||||
|
DockerHost string `yaml:"-" json:"docker_host"`
|
||||||
|
ContainerName string `yaml:"-" json:"container_name"`
|
||||||
|
ContainerID string `yaml:"-" json:"container_id"`
|
||||||
|
ImageName string `yaml:"-" json:"image_name"`
|
||||||
|
PublicPortMapping PortMapping `yaml:"-" json:"public_port_mapping"` // non-zero publicPort:types.Port
|
||||||
|
PrivatePortMapping PortMapping `yaml:"-" json:"private_port_mapping"` // privatePort:types.Port
|
||||||
|
NetworkMode string `yaml:"-" json:"network_mode"`
|
||||||
|
|
||||||
|
Aliases []string `yaml:"-" json:"aliases"`
|
||||||
|
IsExcluded bool `yaml:"-" json:"is_excluded"`
|
||||||
|
IsExplicit bool `yaml:"-" json:"is_explicit"`
|
||||||
|
IsDatabase bool `yaml:"-" json:"is_database"`
|
||||||
|
IdleTimeout string `yaml:"-" json:"idle_timeout"`
|
||||||
|
WakeTimeout string `yaml:"-" json:"wake_timeout"`
|
||||||
|
StopMethod string `yaml:"-" json:"stop_method"`
|
||||||
|
StopTimeout string `yaml:"-" json:"stop_timeout"` // stop_method = "stop" only
|
||||||
|
StopSignal string `yaml:"-" json:"stop_signal"` // stop_method = "stop" | "kill" only
|
||||||
|
Running bool `yaml:"-" json:"running"`
|
||||||
|
}
|
||||||
70
internal/error/builder.go
Normal file
70
internal/error/builder.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if ptr == nil {
|
||||||
|
return
|
||||||
|
} else if *ptr == nil {
|
||||||
|
*ptr = b.Build()
|
||||||
|
} else {
|
||||||
|
(*ptr).With(b.Build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Builder) HasError() bool {
|
||||||
|
return len(b.errors) > 0
|
||||||
|
}
|
||||||
53
internal/error/builder_test.go
Normal file
53
internal/error/builder_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package error
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
296
internal/error/error.go
Normal file
296
internal/error/error.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package error
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
NestedError = *nestedError
|
||||||
|
nestedError struct {
|
||||||
|
subject string
|
||||||
|
err error
|
||||||
|
extras []nestedError
|
||||||
|
}
|
||||||
|
jsonNestedError struct {
|
||||||
|
Subject string
|
||||||
|
Err string
|
||||||
|
Extras []jsonNestedError
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func From(err error) NestedError {
|
||||||
|
if IsNil(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &nestedError{err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromJSON(data []byte) (NestedError, bool) {
|
||||||
|
var j jsonNestedError
|
||||||
|
if err := json.Unmarshal(data, &j); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if j.Err == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
extras := make([]nestedError, len(j.Extras))
|
||||||
|
for i, e := range j.Extras {
|
||||||
|
extra, ok := fromJSONObject(e)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
extras[i] = *extra
|
||||||
|
}
|
||||||
|
return &nestedError{
|
||||||
|
subject: j.Subject,
|
||||||
|
err: errors.New(j.Err),
|
||||||
|
extras: extras,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check is a helper function that
|
||||||
|
// convert (T, error) to (T, NestedError).
|
||||||
|
func Check[T any](obj T, err error) (T, NestedError) {
|
||||||
|
return obj, From(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Join(message string, err ...NestedError) NestedError {
|
||||||
|
extras := make([]nestedError, len(err))
|
||||||
|
nErr := 0
|
||||||
|
for i, e := range err {
|
||||||
|
if e == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extras[i] = *e
|
||||||
|
nErr += 1
|
||||||
|
}
|
||||||
|
if nErr == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &nestedError{
|
||||||
|
err: errors.New(message),
|
||||||
|
extras: extras,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinE(message string, err ...error) NestedError {
|
||||||
|
b := NewBuilder(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) 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)
|
||||||
|
}
|
||||||
|
if ne.subject == "" {
|
||||||
|
ne.subject = subject
|
||||||
|
} else if !strings.ContainsRune(subject, ' ') || strings.ContainsRune(ne.subject, '.') {
|
||||||
|
ne.subject = fmt.Sprintf("%s.%s", subject, ne.subject)
|
||||||
|
} else {
|
||||||
|
ne.subject = fmt.Sprintf("%s > %s", subject, ne.subject)
|
||||||
|
}
|
||||||
|
return ne
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ne NestedError) Subjectf(format string, args ...any) NestedError {
|
||||||
|
if ne == nil {
|
||||||
|
return ne
|
||||||
|
}
|
||||||
|
if strings.Contains(format, "%q") {
|
||||||
|
panic("Subjectf format should not contain %q")
|
||||||
|
}
|
||||||
|
if strings.Contains(format, "%w") {
|
||||||
|
panic("Subjectf format should not contain %w")
|
||||||
|
}
|
||||||
|
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, _ := json.MarshalIndent(ne.JSONObject(), "", " ")
|
||||||
|
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 i := 0; i < level; i++ {
|
||||||
|
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 i := 0; i < level; i++ {
|
||||||
|
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
|
||||||
|
}
|
||||||
109
internal/error/error_test.go
Normal file
109
internal/error/error_test.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
77
internal/error/errors.go
Normal file
77
internal/error/errors.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
43
internal/homepage/homepage.go
Normal file
43
internal/homepage/homepage.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package homepage
|
||||||
|
|
||||||
|
type (
|
||||||
|
HomePageConfig map[string]HomePageCategory
|
||||||
|
HomePageCategory []*HomePageItem
|
||||||
|
|
||||||
|
HomePageItem struct {
|
||||||
|
Show bool `yaml:"show" json:"show"`
|
||||||
|
Name string `yaml:"name" json:"name"`
|
||||||
|
Icon string `yaml:"icon" json:"icon"`
|
||||||
|
URL string `yaml:"url" json:"url"` // alias + domain
|
||||||
|
Category string `yaml:"category" json:"category"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
WidgetConfig map[string]any `yaml:",flow" json:"widget_config"`
|
||||||
|
|
||||||
|
SourceType string `yaml:"-" json:"source_type"`
|
||||||
|
AltURL string `yaml:"-" json:"alt_url"` // original proxy target
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (item *HomePageItem) IsEmpty() bool {
|
||||||
|
return item == nil || (item.Name == "" &&
|
||||||
|
item.Icon == "" &&
|
||||||
|
item.URL == "" &&
|
||||||
|
item.Category == "" &&
|
||||||
|
item.Description == "" &&
|
||||||
|
len(item.WidgetConfig) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHomePageConfig() HomePageConfig {
|
||||||
|
return HomePageConfig(make(map[string]HomePageCategory))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HomePageConfig) Clear() {
|
||||||
|
*c = make(HomePageConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c HomePageConfig) Add(item *HomePageItem) {
|
||||||
|
if c[item.Category] == nil {
|
||||||
|
c[item.Category] = make(HomePageCategory, 0)
|
||||||
|
}
|
||||||
|
c[item.Category] = append(c[item.Category], item)
|
||||||
|
}
|
||||||
99
internal/list-icons.go
Normal file
99
internal/list-icons.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GitHubContents struct { //! keep this, may reuse in future
|
||||||
|
Type string `json:"type"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Sha string `json:"sha"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconsCachePath = "/tmp/icons_cache.json"
|
||||||
|
const updateInterval = 1 * time.Hour
|
||||||
|
|
||||||
|
func ListAvailableIcons() ([]string, error) {
|
||||||
|
owner := "walkxcode"
|
||||||
|
repo := "dashboard-icons"
|
||||||
|
ref := "main"
|
||||||
|
|
||||||
|
var lastUpdate time.Time
|
||||||
|
var icons = make([]string, 0)
|
||||||
|
info, err := os.Stat(iconsCachePath)
|
||||||
|
if err == nil {
|
||||||
|
lastUpdate = info.ModTime().Local()
|
||||||
|
}
|
||||||
|
if time.Since(lastUpdate) < updateInterval {
|
||||||
|
err := utils.LoadJson(iconsCachePath, &icons)
|
||||||
|
if err == nil {
|
||||||
|
return icons, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contents, err := getRepoContents(http.DefaultClient, owner, repo, ref, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, content := range contents {
|
||||||
|
if content.Type != "dir" {
|
||||||
|
icons = append(icons, content.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = utils.SaveJson(iconsCachePath, &icons, 0o644).Error()
|
||||||
|
if err != nil {
|
||||||
|
log.Print("error saving cache", err)
|
||||||
|
}
|
||||||
|
return icons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRepoContents(client *http.Client, owner string, repo string, ref string, path string) ([]GitHubContents, error) {
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/%s/contents/%s?ref=%s", owner, repo, path, ref), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var contents []GitHubContents
|
||||||
|
err = json.Unmarshal(body, &contents)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filesAndDirs := make([]GitHubContents, 0)
|
||||||
|
for _, content := range contents {
|
||||||
|
if content.Type == "dir" {
|
||||||
|
subContents, err := getRepoContents(client, owner, repo, ref, content.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
filesAndDirs = append(filesAndDirs, subContents...)
|
||||||
|
} else {
|
||||||
|
filesAndDirs = append(filesAndDirs, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filesAndDirs, nil
|
||||||
|
}
|
||||||
31
internal/net/http/common.go
Normal file
31
internal/net/http/common.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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/"
|
||||||
32
internal/net/http/content_type.go
Normal file
32
internal/net/http/content_type.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContentType string
|
||||||
|
|
||||||
|
func GetContentType(h http.Header) ContentType {
|
||||||
|
ct := h.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ct, _, err := mime.ParseMediaType(ct)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ContentType(ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ct ContentType) IsHTML() bool {
|
||||||
|
return ct == "text/html" || ct == "application/xhtml+xml"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ct ContentType) IsJSON() bool {
|
||||||
|
return ct == "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ct ContentType) IsPlainText() bool {
|
||||||
|
return ct == "text/plain"
|
||||||
|
}
|
||||||
53
internal/net/http/header_utils.go
Normal file
53
internal/net/http/header_utils.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RemoveHop(h http.Header) {
|
||||||
|
reqUpType := UpgradeType(h)
|
||||||
|
RemoveHopByHopHeaders(h)
|
||||||
|
|
||||||
|
if reqUpType != "" {
|
||||||
|
h.Set("Connection", "Upgrade")
|
||||||
|
h.Set("Upgrade", reqUpType)
|
||||||
|
} else {
|
||||||
|
h.Del("Connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyHeader(dst, src http.Header) {
|
||||||
|
for k, vv := range src {
|
||||||
|
for _, v := range vv {
|
||||||
|
dst.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func FilterHeaders(h http.Header, allowed []string) http.Header {
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make(http.Header)
|
||||||
|
|
||||||
|
for i, header := range allowed {
|
||||||
|
values := h.Values(header)
|
||||||
|
if len(values) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[http.CanonicalHeaderKey(allowed[i])] = append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func HeaderToMap(h http.Header) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
for k, v := range h {
|
||||||
|
if len(v) > 0 {
|
||||||
|
result[k] = v[0] // Take the first value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
83
internal/net/http/middleware/cidr_whitelist.go
Normal file
83
internal/net/http/middleware/cidr_whitelist.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
"github.com/yusing/go-proxy/internal/types"
|
||||||
|
F "github.com/yusing/go-proxy/internal/utils/functional"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cidrWhitelist struct {
|
||||||
|
*cidrWhitelistOpts
|
||||||
|
m *Middleware
|
||||||
|
}
|
||||||
|
|
||||||
|
type cidrWhitelistOpts struct {
|
||||||
|
Allow []*types.CIDR
|
||||||
|
StatusCode int
|
||||||
|
Message string
|
||||||
|
|
||||||
|
cachedAddr F.Map[string, bool] // cache for trusted IPs
|
||||||
|
}
|
||||||
|
|
||||||
|
var CIDRWhiteList = &cidrWhitelist{
|
||||||
|
m: &Middleware{withOptions: NewCIDRWhitelist},
|
||||||
|
}
|
||||||
|
|
||||||
|
var cidrWhitelistDefaults = func() *cidrWhitelistOpts {
|
||||||
|
return &cidrWhitelistOpts{
|
||||||
|
Allow: []*types.CIDR{},
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Message: "IP not allowed",
|
||||||
|
cachedAddr: F.NewMapOf[string, bool](),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCIDRWhitelist(opts OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
wl := new(cidrWhitelist)
|
||||||
|
wl.m = &Middleware{
|
||||||
|
impl: wl,
|
||||||
|
before: wl.checkIP,
|
||||||
|
}
|
||||||
|
wl.cidrWhitelistOpts = cidrWhitelistDefaults()
|
||||||
|
err := Deserialize(opts, wl.cidrWhitelistOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(wl.cidrWhitelistOpts.Allow) == 0 {
|
||||||
|
return nil, E.Missing("allow range")
|
||||||
|
}
|
||||||
|
return wl.m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (wl *cidrWhitelist) checkIP(next http.HandlerFunc, w ResponseWriter, r *Request) {
|
||||||
|
var allow, ok bool
|
||||||
|
if allow, ok = wl.cachedAddr.Load(r.RemoteAddr); !ok {
|
||||||
|
ipStr, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
ipStr = r.RemoteAddr
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
for _, cidr := range wl.cidrWhitelistOpts.Allow {
|
||||||
|
if cidr.Contains(ip) {
|
||||||
|
wl.cachedAddr.Store(r.RemoteAddr, true)
|
||||||
|
allow = true
|
||||||
|
wl.m.AddTracef("client %s is allowed", ipStr).With("allowed CIDR", cidr)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allow {
|
||||||
|
wl.cachedAddr.Store(r.RemoteAddr, false)
|
||||||
|
wl.m.AddTracef("client %s is forbidden", ipStr).With("allowed CIDRs", wl.cidrWhitelistOpts.Allow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allow {
|
||||||
|
w.WriteHeader(wl.StatusCode)
|
||||||
|
w.Write([]byte(wl.Message))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
42
internal/net/http/middleware/cidr_whitelist_test.go
Normal file
42
internal/net/http/middleware/cidr_whitelist_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed test_data/cidr_whitelist_test.yml
|
||||||
|
var testCIDRWhitelistCompose []byte
|
||||||
|
var deny, accept *Middleware
|
||||||
|
|
||||||
|
func TestCIDRWhitelist(t *testing.T) {
|
||||||
|
mids, err := BuildMiddlewaresFromYAML(testCIDRWhitelistCompose)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
deny = mids["deny@file"]
|
||||||
|
accept = mids["accept@file"]
|
||||||
|
if deny == nil || accept == nil {
|
||||||
|
panic("bug occurred")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("deny", func(t *testing.T) {
|
||||||
|
for range 10 {
|
||||||
|
result, err := newMiddlewareTest(deny, nil)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
ExpectEqual(t, result.ResponseStatus, cidrWhitelistDefaults().StatusCode)
|
||||||
|
ExpectEqual(t, string(result.Data), cidrWhitelistDefaults().Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("accept", func(t *testing.T) {
|
||||||
|
for range 10 {
|
||||||
|
result, err := newMiddlewareTest(accept, nil)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
ExpectEqual(t, result.ResponseStatus, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
118
internal/net/http/middleware/cloudflare_real_ip.go
Normal file
118
internal/net/http/middleware/cloudflare_real_ip.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
"github.com/yusing/go-proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfIPv4CIDRsEndpoint = "https://www.cloudflare.com/ips-v4"
|
||||||
|
cfIPv6CIDRsEndpoint = "https://www.cloudflare.com/ips-v6"
|
||||||
|
cfCIDRsUpdateInterval = time.Hour
|
||||||
|
cfCIDRsUpdateRetryInterval = 3 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfCIDRsLastUpdate time.Time
|
||||||
|
cfCIDRsMu sync.Mutex
|
||||||
|
cfCIDRsLogger = logrus.WithField("middleware", "CloudflareRealIP")
|
||||||
|
)
|
||||||
|
|
||||||
|
var CloudflareRealIP = &realIP{
|
||||||
|
m: &Middleware{withOptions: NewCloudflareRealIP},
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCloudflareRealIP(_ OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
cri := new(realIP)
|
||||||
|
cri.m = &Middleware{
|
||||||
|
impl: cri,
|
||||||
|
before: func(next http.HandlerFunc, w ResponseWriter, r *Request) {
|
||||||
|
cidrs := tryFetchCFCIDR()
|
||||||
|
if cidrs != nil {
|
||||||
|
cri.From = cidrs
|
||||||
|
}
|
||||||
|
cri.setRealIP(r)
|
||||||
|
next(w, r)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cri.realIPOpts = &realIPOpts{
|
||||||
|
Header: "CF-Connecting-IP",
|
||||||
|
Recursive: true,
|
||||||
|
}
|
||||||
|
return cri.m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryFetchCFCIDR() (cfCIDRs []*types.CIDR) {
|
||||||
|
if time.Since(cfCIDRsLastUpdate) < cfCIDRsUpdateInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfCIDRsMu.Lock()
|
||||||
|
defer cfCIDRsMu.Unlock()
|
||||||
|
|
||||||
|
if time.Since(cfCIDRsLastUpdate) < cfCIDRsUpdateInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.IsTest {
|
||||||
|
cfCIDRs = []*types.CIDR{
|
||||||
|
{IP: net.IPv4(127, 0, 0, 1), Mask: net.IPv4Mask(255, 0, 0, 0)},
|
||||||
|
{IP: net.IPv4(10, 0, 0, 0), Mask: net.IPv4Mask(255, 0, 0, 0)},
|
||||||
|
{IP: net.IPv4(172, 16, 0, 0), Mask: net.IPv4Mask(255, 255, 0, 0)},
|
||||||
|
{IP: net.IPv4(192, 168, 0, 0), Mask: net.IPv4Mask(255, 255, 255, 0)},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cfCIDRs = make([]*types.CIDR, 0, 30)
|
||||||
|
err := errors.Join(
|
||||||
|
fetchUpdateCFIPRange(cfIPv4CIDRsEndpoint, cfCIDRs),
|
||||||
|
fetchUpdateCFIPRange(cfIPv6CIDRsEndpoint, cfCIDRs),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
cfCIDRsLastUpdate = time.Now().Add(-cfCIDRsUpdateRetryInterval - cfCIDRsUpdateInterval)
|
||||||
|
cfCIDRsLogger.Errorf("failed to update cloudflare range: %s, retry in %s", err, cfCIDRsUpdateRetryInterval)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfCIDRsLastUpdate = time.Now()
|
||||||
|
cfCIDRsLogger.Debugf("cloudflare CIDR range updated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchUpdateCFIPRange(endpoint string, cfCIDRs []*types.CIDR) error {
|
||||||
|
resp, err := http.Get(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, cidr, err := net.ParseCIDR(line)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cloudflare responeded an invalid CIDR: %s", line)
|
||||||
|
} else {
|
||||||
|
cfCIDRs = append(cfCIDRs, (*types.CIDR)(cidr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
74
internal/net/http/middleware/custom_error_page.go
Normal file
74
internal/net/http/middleware/custom_error_page.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/yusing/go-proxy/internal/api/v1/error_page"
|
||||||
|
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 := error_page.GetErrorPageByStatus(resp.StatusCode)
|
||||||
|
if ok {
|
||||||
|
errPageLogger.Debugf("error page for status %d loaded", resp.StatusCode)
|
||||||
|
io.Copy(io.Discard, resp.Body) // drain the original body
|
||||||
|
resp.Body.Close()
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(errorPage))
|
||||||
|
resp.ContentLength = int64(len(errorPage))
|
||||||
|
resp.Header.Set("Content-Length", fmt.Sprint(len(errorPage)))
|
||||||
|
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
} else {
|
||||||
|
errPageLogger.Errorf("unable to load error page for status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServeStaticErrorPageFile(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path != "" && path[0] != '/' {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, gpHTTP.StaticFilePathPrefix) {
|
||||||
|
filename := path[len(gpHTTP.StaticFilePathPrefix):]
|
||||||
|
file, ok := error_page.GetStaticFile(filename)
|
||||||
|
if !ok {
|
||||||
|
errPageLogger.Errorf("unable to load resource %s", filename)
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
switch ext {
|
||||||
|
case ".html":
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
case ".js":
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
case ".css":
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
default:
|
||||||
|
errPageLogger.Errorf("unexpected file type %q for %s", ext, filename)
|
||||||
|
}
|
||||||
|
w.Write(file)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var errPageLogger = logrus.WithField("middleware", "error_page")
|
||||||
233
internal/net/http/middleware/forward_auth.go
Normal file
233
internal/net/http/middleware/forward_auth.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
155
internal/net/http/middleware/middleware.go
Normal file
155
internal/net/http/middleware/middleware.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
gpHTTP "github.com/yusing/go-proxy/internal/net/http"
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Error = E.NestedError
|
||||||
|
|
||||||
|
ReverseProxy = gpHTTP.ReverseProxy
|
||||||
|
ProxyRequest = gpHTTP.ProxyRequest
|
||||||
|
Request = http.Request
|
||||||
|
Response = http.Response
|
||||||
|
ResponseWriter = http.ResponseWriter
|
||||||
|
Header = http.Header
|
||||||
|
Cookie = http.Cookie
|
||||||
|
|
||||||
|
BeforeFunc func(next http.HandlerFunc, w ResponseWriter, r *Request)
|
||||||
|
RewriteFunc func(req *Request)
|
||||||
|
ModifyResponseFunc func(resp *Response) error
|
||||||
|
CloneWithOptFunc func(opts OptionsRaw) (*Middleware, E.NestedError)
|
||||||
|
|
||||||
|
OptionsRaw = map[string]any
|
||||||
|
Options any
|
||||||
|
|
||||||
|
Middleware struct {
|
||||||
|
name string
|
||||||
|
|
||||||
|
before BeforeFunc // runs before ReverseProxy.ServeHTTP
|
||||||
|
modifyResponse ModifyResponseFunc // runs after ReverseProxy.ModifyResponse
|
||||||
|
|
||||||
|
withOptions CloneWithOptFunc
|
||||||
|
impl any
|
||||||
|
|
||||||
|
parent *Middleware
|
||||||
|
children []*Middleware
|
||||||
|
trace bool
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var Deserialize = U.Deserialize
|
||||||
|
|
||||||
|
func Rewrite(r RewriteFunc) BeforeFunc {
|
||||||
|
return func(next http.HandlerFunc, w ResponseWriter, req *Request) {
|
||||||
|
r(req)
|
||||||
|
next(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) Name() string {
|
||||||
|
return m.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) Fullname() string {
|
||||||
|
if m.parent != nil {
|
||||||
|
return m.parent.Fullname() + "." + m.name
|
||||||
|
}
|
||||||
|
return m.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) String() string {
|
||||||
|
return m.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.MarshalIndent(map[string]any{
|
||||||
|
"name": m.name,
|
||||||
|
"options": m.impl,
|
||||||
|
}, "", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) WithOptionsClone(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
if len(optsRaw) != 0 && m.withOptions != nil {
|
||||||
|
if mWithOpt, err := m.withOptions(optsRaw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else {
|
||||||
|
return mWithOpt, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithOptionsClone is called only once
|
||||||
|
// set withOptions and labelParser will not be used after that
|
||||||
|
return &Middleware{
|
||||||
|
m.name,
|
||||||
|
m.before,
|
||||||
|
m.modifyResponse,
|
||||||
|
nil,
|
||||||
|
m.impl,
|
||||||
|
m.parent,
|
||||||
|
m.children,
|
||||||
|
false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check conflict or duplicates
|
||||||
|
func PatchReverseProxy(rpName string, rp *ReverseProxy, middlewaresMap map[string]OptionsRaw) (res E.NestedError) {
|
||||||
|
middlewares := make([]*Middleware, 0, len(middlewaresMap))
|
||||||
|
|
||||||
|
invalidM := E.NewBuilder("invalid middlewares")
|
||||||
|
invalidOpts := E.NewBuilder("invalid options")
|
||||||
|
defer func() {
|
||||||
|
invalidM.Add(invalidOpts.Build())
|
||||||
|
invalidM.To(&res)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for name, opts := range middlewaresMap {
|
||||||
|
m, ok := Get(name)
|
||||||
|
if !ok {
|
||||||
|
invalidM.Add(E.NotExist("middleware", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := m.WithOptionsClone(opts)
|
||||||
|
if err != nil {
|
||||||
|
invalidOpts.Add(err.Subject(name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
middlewares = append(middlewares, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if invalidM.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchReverseProxy(rpName, rp, middlewares)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchReverseProxy(rpName string, rp *ReverseProxy, middlewares []*Middleware) {
|
||||||
|
mid := BuildMiddlewareFromChain(rpName, middlewares)
|
||||||
|
|
||||||
|
if mid.before != nil {
|
||||||
|
ori := rp.ServeHTTP
|
||||||
|
rp.ServeHTTP = func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mid.before(ori, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if mid.modifyResponse != nil {
|
||||||
|
if rp.ModifyResponse != nil {
|
||||||
|
ori := rp.ModifyResponse
|
||||||
|
rp.ModifyResponse = func(res *http.Response) error {
|
||||||
|
return errors.Join(mid.modifyResponse(res), ori(res))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rp.ModifyResponse = mid.modifyResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
internal/net/http/middleware/middleware_builder.go
Normal file
114
internal/net/http/middleware/middleware_builder.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BuildMiddlewaresFromComposeFile(filePath string) (map[string]*Middleware, E.NestedError) {
|
||||||
|
fileContent, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.FailWith("read middleware compose file", err)
|
||||||
|
}
|
||||||
|
return BuildMiddlewaresFromYAML(fileContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMiddlewaresFromYAML(data []byte) (middlewares map[string]*Middleware, outErr E.NestedError) {
|
||||||
|
b := E.NewBuilder("middlewares compile errors")
|
||||||
|
defer b.To(&outErr)
|
||||||
|
|
||||||
|
var rawMap map[string][]map[string]any
|
||||||
|
err := yaml.Unmarshal(data, &rawMap)
|
||||||
|
if err != nil {
|
||||||
|
b.Add(E.FailWith("yaml unmarshal", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
middlewares = make(map[string]*Middleware)
|
||||||
|
for name, defs := range rawMap {
|
||||||
|
chainErr := E.NewBuilder(name)
|
||||||
|
chain := make([]*Middleware, 0, len(defs))
|
||||||
|
for i, def := range defs {
|
||||||
|
if def["use"] == nil || def["use"] == "" {
|
||||||
|
chainErr.Add(E.Missing("use").Subjectf(".%d", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
baseName := def["use"].(string)
|
||||||
|
base, ok := Get(baseName)
|
||||||
|
if !ok {
|
||||||
|
base, ok = middlewares[baseName]
|
||||||
|
if !ok {
|
||||||
|
chainErr.Add(E.NotExist("middleware", baseName).Subjectf(".%d", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(def, "use")
|
||||||
|
m, err := base.WithOptionsClone(def)
|
||||||
|
m.name = fmt.Sprintf("%s[%d]", name, i)
|
||||||
|
if err != nil {
|
||||||
|
chainErr.Add(err.Subjectf("item%d", i))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chain = append(chain, m)
|
||||||
|
}
|
||||||
|
if chainErr.HasError() {
|
||||||
|
b.Add(chainErr.Build())
|
||||||
|
} else {
|
||||||
|
middlewares[name+"@file"] = BuildMiddlewareFromChain(name, chain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check conflict or duplicates
|
||||||
|
func BuildMiddlewareFromChain(name string, chain []*Middleware) *Middleware {
|
||||||
|
m := &Middleware{name: name, children: chain}
|
||||||
|
|
||||||
|
var befores []*Middleware
|
||||||
|
var modResps []*Middleware
|
||||||
|
|
||||||
|
for _, comp := range chain {
|
||||||
|
if comp.before != nil {
|
||||||
|
befores = append(befores, comp)
|
||||||
|
}
|
||||||
|
if comp.modifyResponse != nil {
|
||||||
|
modResps = append(modResps, comp)
|
||||||
|
}
|
||||||
|
comp.parent = m
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(befores) > 0 {
|
||||||
|
m.before = buildBefores(befores)
|
||||||
|
}
|
||||||
|
if len(modResps) > 0 {
|
||||||
|
m.modifyResponse = func(res *Response) error {
|
||||||
|
b := E.NewBuilder("errors in middleware")
|
||||||
|
for _, mr := range modResps {
|
||||||
|
b.Add(E.From(mr.modifyResponse(res)).Subject(mr.name))
|
||||||
|
}
|
||||||
|
return b.Build().Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.IsDebug {
|
||||||
|
m.EnableTrace()
|
||||||
|
m.AddTracef("middleware created")
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildBefores(befores []*Middleware) BeforeFunc {
|
||||||
|
if len(befores) == 1 {
|
||||||
|
return befores[0].before
|
||||||
|
}
|
||||||
|
nextBefores := buildBefores(befores[1:])
|
||||||
|
return func(next http.HandlerFunc, w ResponseWriter, r *Request) {
|
||||||
|
befores[0].before(func(w ResponseWriter, r *Request) {
|
||||||
|
nextBefores(next, w, r)
|
||||||
|
}, w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/net/http/middleware/middleware_builder_test.go
Normal file
22
internal/net/http/middleware/middleware_builder_test.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed test_data/middleware_compose.yml
|
||||||
|
var testMiddlewareCompose []byte
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
middlewares, err := BuildMiddlewaresFromYAML(testMiddlewareCompose)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
_, err = E.Check(json.MarshalIndent(middlewares, "", " "))
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
// t.Log(string(data))
|
||||||
|
// TODO: test
|
||||||
|
}
|
||||||
78
internal/net/http/middleware/middlewares.go
Normal file
78
internal/net/http/middleware/middlewares.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var middlewares map[string]*Middleware
|
||||||
|
|
||||||
|
func Get(name string) (middleware *Middleware, ok bool) {
|
||||||
|
middleware, ok = middlewares[U.ToLowerNoSnake(name)]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func All() map[string]*Middleware {
|
||||||
|
return middlewares
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize middleware names and label parsers
|
||||||
|
func init() {
|
||||||
|
middlewares = map[string]*Middleware{
|
||||||
|
"setxforwarded": SetXForwarded,
|
||||||
|
"hidexforwarded": HideXForwarded,
|
||||||
|
"redirecthttp": RedirectHTTP,
|
||||||
|
"forwardauth": ForwardAuth.m,
|
||||||
|
"modifyresponse": ModifyResponse.m,
|
||||||
|
"modifyrequest": ModifyRequest.m,
|
||||||
|
"errorpage": CustomErrorPage,
|
||||||
|
"customerrorpage": CustomErrorPage,
|
||||||
|
"realip": RealIP.m,
|
||||||
|
"cloudflarerealip": CloudflareRealIP.m,
|
||||||
|
"cidrwhitelist": CIDRWhiteList.m,
|
||||||
|
}
|
||||||
|
names := make(map[*Middleware][]string)
|
||||||
|
for name, m := range middlewares {
|
||||||
|
names[m] = append(names[m], http.CanonicalHeaderKey(name))
|
||||||
|
}
|
||||||
|
for m, names := range names {
|
||||||
|
if len(names) > 1 {
|
||||||
|
m.name = fmt.Sprintf("%s (a.k.a. %s)", names[0], strings.Join(names[1:], ", "))
|
||||||
|
} else {
|
||||||
|
m.name = names[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadComposeFiles() {
|
||||||
|
b := E.NewBuilder("failed to load middlewares")
|
||||||
|
middlewareDefs, err := U.ListFiles(common.MiddlewareComposeBasePath, 0)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("failed to list middleware definitions: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, defFile := range middlewareDefs {
|
||||||
|
mws, err := BuildMiddlewaresFromComposeFile(defFile)
|
||||||
|
for name, m := range mws {
|
||||||
|
if _, ok := middlewares[name]; ok {
|
||||||
|
b.Add(E.Duplicated("middleware", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
middlewares[U.ToLowerNoSnake(name)] = m
|
||||||
|
logger.Infof("middleware %s loaded from %s", name, path.Base(defFile))
|
||||||
|
}
|
||||||
|
b.Add(err.Subject(path.Base(defFile)))
|
||||||
|
}
|
||||||
|
if b.HasError() {
|
||||||
|
logger.Error(b.Build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger = logrus.WithField("module", "middlewares")
|
||||||
61
internal/net/http/middleware/modify_request.go
Normal file
61
internal/net/http/middleware/modify_request.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
modifyRequest struct {
|
||||||
|
*modifyRequestOpts
|
||||||
|
m *Middleware
|
||||||
|
}
|
||||||
|
// order: set_headers -> add_headers -> hide_headers
|
||||||
|
modifyRequestOpts struct {
|
||||||
|
SetHeaders map[string]string
|
||||||
|
AddHeaders map[string]string
|
||||||
|
HideHeaders []string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var ModifyRequest = &modifyRequest{
|
||||||
|
m: &Middleware{withOptions: NewModifyRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModifyRequest(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
mr := new(modifyRequest)
|
||||||
|
var mrFunc RewriteFunc
|
||||||
|
if common.IsDebug {
|
||||||
|
mrFunc = mr.modifyRequestWithTrace
|
||||||
|
} else {
|
||||||
|
mrFunc = mr.modifyRequest
|
||||||
|
}
|
||||||
|
mr.m = &Middleware{
|
||||||
|
impl: mr,
|
||||||
|
before: Rewrite(mrFunc),
|
||||||
|
}
|
||||||
|
mr.modifyRequestOpts = new(modifyRequestOpts)
|
||||||
|
err := Deserialize(optsRaw, mr.modifyRequestOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mr.m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mr *modifyRequest) modifyRequest(req *Request) {
|
||||||
|
for k, v := range mr.SetHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range mr.AddHeaders {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
for _, k := range mr.HideHeaders {
|
||||||
|
req.Header.Del(k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mr *modifyRequest) modifyRequestWithTrace(req *Request) {
|
||||||
|
mr.m.AddTraceRequest("before modify request", req)
|
||||||
|
mr.modifyRequest(req)
|
||||||
|
mr.m.AddTraceRequest("after modify request", req)
|
||||||
|
}
|
||||||
34
internal/net/http/middleware/modify_request_test.go
Normal file
34
internal/net/http/middleware/modify_request_test.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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"), "")
|
||||||
|
})
|
||||||
|
}
|
||||||
61
internal/net/http/middleware/modify_response.go
Normal file
61
internal/net/http/middleware/modify_response.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
modifyResponse struct {
|
||||||
|
*modifyResponseOpts
|
||||||
|
m *Middleware
|
||||||
|
}
|
||||||
|
// order: set_headers -> add_headers -> hide_headers
|
||||||
|
modifyResponseOpts struct {
|
||||||
|
SetHeaders map[string]string
|
||||||
|
AddHeaders map[string]string
|
||||||
|
HideHeaders []string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var ModifyResponse = &modifyResponse{
|
||||||
|
m: &Middleware{withOptions: NewModifyResponse},
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModifyResponse(optsRaw OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
mr := new(modifyResponse)
|
||||||
|
mr.m = &Middleware{impl: mr}
|
||||||
|
if common.IsDebug {
|
||||||
|
mr.m.modifyResponse = mr.modifyResponseWithTrace
|
||||||
|
} else {
|
||||||
|
mr.m.modifyResponse = mr.modifyResponse
|
||||||
|
}
|
||||||
|
mr.modifyResponseOpts = new(modifyResponseOpts)
|
||||||
|
err := Deserialize(optsRaw, mr.modifyResponseOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return mr.m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mr *modifyResponse) modifyResponse(resp *http.Response) error {
|
||||||
|
for k, v := range mr.SetHeaders {
|
||||||
|
resp.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
for k, v := range mr.AddHeaders {
|
||||||
|
resp.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
for _, k := range mr.HideHeaders {
|
||||||
|
resp.Header.Del(k)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mr *modifyResponse) modifyResponseWithTrace(resp *http.Response) error {
|
||||||
|
mr.m.AddTraceResponse("before modify response", resp)
|
||||||
|
err := mr.modifyResponse(resp)
|
||||||
|
mr.m.AddTraceResponse("after modify response", resp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
35
internal/net/http/middleware/modify_response_test.go
Normal file
35
internal/net/http/middleware/modify_response_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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"), "")
|
||||||
|
})
|
||||||
|
}
|
||||||
115
internal/net/http/middleware/real_ip.go
Normal file
115
internal/net/http/middleware/real_ip.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
"github.com/yusing/go-proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// https://nginx.org/en/docs/http/ngx_http_realip_module.html
|
||||||
|
|
||||||
|
type realIP struct {
|
||||||
|
*realIPOpts
|
||||||
|
m *Middleware
|
||||||
|
}
|
||||||
|
|
||||||
|
type realIPOpts struct {
|
||||||
|
// Header is the name of the header to use for the real client IP
|
||||||
|
Header string
|
||||||
|
// From is a list of Address / CIDRs to trust
|
||||||
|
From []*types.CIDR
|
||||||
|
/*
|
||||||
|
If recursive search is disabled,
|
||||||
|
the original client address that matches one of the trusted addresses is replaced by
|
||||||
|
the last address sent in the request header field defined by the Header field.
|
||||||
|
If recursive search is enabled,
|
||||||
|
the original client address that matches one of the trusted addresses is replaced by
|
||||||
|
the last non-trusted address sent in the request header field.
|
||||||
|
*/
|
||||||
|
Recursive bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var RealIP = &realIP{
|
||||||
|
m: &Middleware{withOptions: NewRealIP},
|
||||||
|
}
|
||||||
|
|
||||||
|
var realIPOptsDefault = func() *realIPOpts {
|
||||||
|
return &realIPOpts{
|
||||||
|
Header: "X-Real-IP",
|
||||||
|
From: []*types.CIDR{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRealIP(opts OptionsRaw) (*Middleware, E.NestedError) {
|
||||||
|
riWithOpts := new(realIP)
|
||||||
|
riWithOpts.m = &Middleware{
|
||||||
|
impl: riWithOpts,
|
||||||
|
before: Rewrite(riWithOpts.setRealIP),
|
||||||
|
}
|
||||||
|
riWithOpts.realIPOpts = realIPOptsDefault()
|
||||||
|
err := Deserialize(opts, riWithOpts.realIPOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return riWithOpts.m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ri *realIP) isInCIDRList(ip net.IP) bool {
|
||||||
|
for _, CIDR := range ri.From {
|
||||||
|
if CIDR.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// not in any CIDR
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ri *realIP) setRealIP(req *Request) {
|
||||||
|
clientIPStr, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
clientIPStr = req.RemoteAddr
|
||||||
|
}
|
||||||
|
clientIP := net.ParseIP(clientIPStr)
|
||||||
|
|
||||||
|
var isTrusted = false
|
||||||
|
for _, CIDR := range ri.From {
|
||||||
|
if CIDR.Contains(clientIP) {
|
||||||
|
isTrusted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isTrusted {
|
||||||
|
ri.m.AddTracef("client ip %s is not trusted", clientIP).With("allowed CIDRs", ri.From)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var realIPs = req.Header.Values(ri.Header)
|
||||||
|
var lastNonTrustedIP string
|
||||||
|
|
||||||
|
if len(realIPs) == 0 {
|
||||||
|
ri.m.AddTracef("no real ip found in header %s", ri.Header).WithRequest(req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ri.Recursive {
|
||||||
|
lastNonTrustedIP = realIPs[len(realIPs)-1]
|
||||||
|
} else {
|
||||||
|
for _, r := range realIPs {
|
||||||
|
if !ri.isInCIDRList(net.ParseIP(r)) {
|
||||||
|
lastNonTrustedIP = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastNonTrustedIP == "" {
|
||||||
|
ri.m.AddTracef("no non-trusted ip found").With("allowed CIDRs", ri.From).With("ips", realIPs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.RemoteAddr = lastNonTrustedIP
|
||||||
|
req.Header.Set(ri.Header, lastNonTrustedIP)
|
||||||
|
req.Header.Set("X-Real-IP", lastNonTrustedIP)
|
||||||
|
req.Header.Set(xForwardedFor, lastNonTrustedIP)
|
||||||
|
ri.m.AddTracef("set real ip %s", lastNonTrustedIP)
|
||||||
|
}
|
||||||
77
internal/net/http/middleware/real_ip_test.go
Normal file
77
internal/net/http/middleware/real_ip_test.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/types"
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetRealIPOpts(t *testing.T) {
|
||||||
|
opts := OptionsRaw{
|
||||||
|
"header": "X-Real-IP",
|
||||||
|
"from": []string{
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
},
|
||||||
|
"recursive": true,
|
||||||
|
}
|
||||||
|
optExpected := &realIPOpts{
|
||||||
|
Header: "X-Real-IP",
|
||||||
|
From: []*types.CIDR{
|
||||||
|
{
|
||||||
|
IP: net.ParseIP("127.0.0.0"),
|
||||||
|
Mask: net.IPv4Mask(255, 0, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IP: net.ParseIP("192.168.0.0"),
|
||||||
|
Mask: net.IPv4Mask(255, 255, 0, 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
IP: net.ParseIP("172.16.0.0"),
|
||||||
|
Mask: net.IPv4Mask(255, 240, 0, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Recursive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
ri, err := NewRealIP(opts)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
ExpectEqual(t, ri.impl.(*realIP).Header, optExpected.Header)
|
||||||
|
ExpectEqual(t, ri.impl.(*realIP).Recursive, optExpected.Recursive)
|
||||||
|
for i, CIDR := range ri.impl.(*realIP).From {
|
||||||
|
ExpectEqual(t, CIDR.String(), optExpected.From[i].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRealIP(t *testing.T) {
|
||||||
|
const (
|
||||||
|
testHeader = "X-Real-IP"
|
||||||
|
testRealIP = "192.168.1.1"
|
||||||
|
)
|
||||||
|
opts := OptionsRaw{
|
||||||
|
"header": testHeader,
|
||||||
|
"from": []string{"0.0.0.0/0"},
|
||||||
|
}
|
||||||
|
optsMr := OptionsRaw{
|
||||||
|
"set_headers": map[string]string{testHeader: testRealIP},
|
||||||
|
}
|
||||||
|
realip, err := NewRealIP(opts)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
|
||||||
|
mr, err := NewModifyRequest(optsMr)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
|
||||||
|
mid := BuildMiddlewareFromChain("test", []*Middleware{mr, realip})
|
||||||
|
|
||||||
|
result, err := newMiddlewareTest(mid, nil)
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
t.Log(traces)
|
||||||
|
ExpectEqual(t, result.ResponseStatus, http.StatusOK)
|
||||||
|
ExpectEqual(t, strings.Split(result.RemoteAddr, ":")[0], testRealIP)
|
||||||
|
ExpectEqual(t, result.RequestHeaders.Get(xForwardedFor), testRealIP)
|
||||||
|
}
|
||||||
19
internal/net/http/middleware/redirect_http.go
Normal file
19
internal/net/http/middleware/redirect_http.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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)
|
||||||
|
},
|
||||||
|
}
|
||||||
26
internal/net/http/middleware/redirect_http_test.go
Normal file
26
internal/net/http/middleware/redirect_http_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
. "github.com/yusing/go-proxy/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRedirectToHTTPs(t *testing.T) {
|
||||||
|
result, err := newMiddlewareTest(RedirectHTTP, &testArgs{
|
||||||
|
scheme: "http",
|
||||||
|
})
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
ExpectEqual(t, result.ResponseStatus, http.StatusTemporaryRedirect)
|
||||||
|
ExpectEqual(t, result.ResponseHeaders.Get("Location"), "https://"+testHost+":"+common.ProxyHTTPSPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoRedirect(t *testing.T) {
|
||||||
|
result, err := newMiddlewareTest(RedirectHTTP, &testArgs{
|
||||||
|
scheme: "https",
|
||||||
|
})
|
||||||
|
ExpectNoError(t, err.Error())
|
||||||
|
ExpectEqual(t, result.ResponseStatus, http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
deny:
|
||||||
|
- use: ModifyRequest
|
||||||
|
setHeaders:
|
||||||
|
X-Real-IP: 192.168.1.1:1234
|
||||||
|
- use: RealIP
|
||||||
|
header: X-Real-IP
|
||||||
|
from:
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- use: CIDRWhitelist
|
||||||
|
allow:
|
||||||
|
- 192.168.0.0/24
|
||||||
|
accept:
|
||||||
|
- use: ModifyRequest
|
||||||
|
setHeaders:
|
||||||
|
X-Real-IP: 192.168.0.1:1234
|
||||||
|
- use: RealIP
|
||||||
|
header: X-Real-IP
|
||||||
|
from:
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- use: CIDRWhitelist
|
||||||
|
allow:
|
||||||
|
- 192.168.0.0/24
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
theGreatPretender:
|
||||||
|
- use: HideXForwarded
|
||||||
|
- use: ModifyRequest
|
||||||
|
setHeaders:
|
||||||
|
X-Real-IP: 6.6.6.6
|
||||||
|
- use: ModifyResponse
|
||||||
|
hideHeaders:
|
||||||
|
- X-Test3
|
||||||
|
- X-Test4
|
||||||
|
|
||||||
|
notAuthenticAuthentik:
|
||||||
|
- use: RedirectHTTP
|
||||||
|
- use: ForwardAuth
|
||||||
|
address: https://authentik.company
|
||||||
|
trustForwardHeader: true
|
||||||
|
addAuthCookiesToResponse:
|
||||||
|
- session_id
|
||||||
|
- user_id
|
||||||
|
authResponseHeaders:
|
||||||
|
- X-Auth-SessionID
|
||||||
|
- X-Auth-UserID
|
||||||
|
- use: CustomErrorPage
|
||||||
|
|
||||||
|
realIPAuthentik:
|
||||||
|
- use: RedirectHTTP
|
||||||
|
- use: RealIP
|
||||||
|
header: X-Real-IP
|
||||||
|
from:
|
||||||
|
- "127.0.0.0/8"
|
||||||
|
- "192.168.0.0/16"
|
||||||
|
- "172.16.0.0/12"
|
||||||
|
recursive: true
|
||||||
|
- use: ForwardAuth
|
||||||
|
address: https://authentik.company
|
||||||
|
trustForwardHeader: true
|
||||||
|
|
||||||
|
testFakeRealIP:
|
||||||
|
- use: ModifyRequest
|
||||||
|
setHeaders:
|
||||||
|
CF-Connecting-IP: 127.0.0.1
|
||||||
|
- use: CloudflareRealIP
|
||||||
17
internal/net/http/middleware/test_data/sample_headers.json
Normal file
17
internal/net/http/middleware/test_data/sample_headers.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||||
|
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||||
|
"Accept-Language": "en,zh-HK;q=0.9,zh-TW;q=0.8,zh-CN;q=0.7,zh;q=0.6",
|
||||||
|
"Dnt": "1",
|
||||||
|
"Host": "localhost",
|
||||||
|
"Priority": "u=0, i",
|
||||||
|
"Sec-Ch-Ua": "\"Chromium\";v=\"129\", \"Not=A?Brand\";v=\"8\"",
|
||||||
|
"Sec-Ch-Ua-Mobile": "?0",
|
||||||
|
"Sec-Ch-Ua-Platform": "\"Windows\"",
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
|
||||||
|
}
|
||||||
133
internal/net/http/middleware/test_utils.go
Normal file
133
internal/net/http/middleware/test_utils.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/yusing/go-proxy/internal/common"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
gpHTTP "github.com/yusing/go-proxy/internal/net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed test_data/sample_headers.json
|
||||||
|
var testHeadersRaw []byte
|
||||||
|
var testHeaders http.Header
|
||||||
|
|
||||||
|
const testHost = "example.com"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if !common.IsTest {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp := map[string]string{}
|
||||||
|
err := json.Unmarshal(testHeadersRaw, &tmp)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
testHeaders = http.Header{}
|
||||||
|
for k, v := range tmp {
|
||||||
|
testHeaders.Set(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestRecorder struct {
|
||||||
|
parent http.RoundTripper
|
||||||
|
headers http.Header
|
||||||
|
remoteAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *requestRecorder) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
rt.headers = req.Header
|
||||||
|
rt.remoteAddr = req.RemoteAddr
|
||||||
|
if rt.parent != nil {
|
||||||
|
return rt.parent.RoundTrip(req)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: testHeaders,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString("OK")),
|
||||||
|
Request: req,
|
||||||
|
TLS: req.TLS,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResult struct {
|
||||||
|
RequestHeaders http.Header
|
||||||
|
ResponseHeaders http.Header
|
||||||
|
ResponseStatus int
|
||||||
|
RemoteAddr string
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type testArgs struct {
|
||||||
|
middlewareOpt OptionsRaw
|
||||||
|
proxyURL string
|
||||||
|
body []byte
|
||||||
|
scheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMiddlewareTest(middleware *Middleware, args *testArgs) (*TestResult, E.NestedError) {
|
||||||
|
var body io.Reader
|
||||||
|
var rr = new(requestRecorder)
|
||||||
|
var proxyURL *url.URL
|
||||||
|
var requestTarget string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if args == nil {
|
||||||
|
args = new(testArgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.body != nil {
|
||||||
|
body = bytes.NewReader(args.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.scheme == "" || args.scheme == "http" {
|
||||||
|
requestTarget = "http://" + testHost
|
||||||
|
} else if args.scheme == "https" {
|
||||||
|
requestTarget = "https://" + testHost
|
||||||
|
} else {
|
||||||
|
panic("typo?")
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, requestTarget, body)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
if args.scheme == "https" && req.TLS == nil {
|
||||||
|
panic("bug occurred")
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.proxyURL != "" {
|
||||||
|
proxyURL, err = url.Parse(args.proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
rr.parent = http.DefaultTransport
|
||||||
|
} else {
|
||||||
|
proxyURL, _ = url.Parse("https://" + testHost) // dummy url, no actual effect
|
||||||
|
}
|
||||||
|
rp := gpHTTP.NewReverseProxy(proxyURL, rr)
|
||||||
|
mid, setOptErr := middleware.WithOptionsClone(args.middlewareOpt)
|
||||||
|
if setOptErr != nil {
|
||||||
|
return nil, setOptErr
|
||||||
|
}
|
||||||
|
patchReverseProxy(middleware.name, rp, []*Middleware{mid})
|
||||||
|
rp.ServeHTTP(w, req)
|
||||||
|
resp := w.Result()
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, E.From(err)
|
||||||
|
}
|
||||||
|
return &TestResult{
|
||||||
|
RequestHeaders: rr.headers,
|
||||||
|
ResponseHeaders: resp.Header,
|
||||||
|
ResponseStatus: resp.StatusCode,
|
||||||
|
RemoteAddr: rr.remoteAddr,
|
||||||
|
Data: data,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
113
internal/net/http/middleware/trace.go
Normal file
113
internal/net/http/middleware/trace.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gpHTTP "github.com/yusing/go-proxy/internal/net/http"
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Trace struct {
|
||||||
|
Time string `json:"time,omitempty"`
|
||||||
|
Caller string `json:"caller,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Message string `json:"msg"`
|
||||||
|
ReqHeaders map[string]string `json:"req_headers,omitempty"`
|
||||||
|
RespHeaders map[string]string `json:"resp_headers,omitempty"`
|
||||||
|
RespStatus int `json:"resp_status,omitempty"`
|
||||||
|
Additional map[string]any `json:"additional,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Traces []*Trace
|
||||||
|
|
||||||
|
var traces = Traces{}
|
||||||
|
var tracesMu sync.Mutex
|
||||||
|
|
||||||
|
const MaxTraceNum = 100
|
||||||
|
|
||||||
|
func GetAllTrace() []*Trace {
|
||||||
|
return traces
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *Trace) WithRequest(req *Request) *Trace {
|
||||||
|
if tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr.URL = req.RequestURI
|
||||||
|
tr.ReqHeaders = gpHTTP.HeaderToMap(req.Header)
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *Trace) WithResponse(resp *Response) *Trace {
|
||||||
|
if tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
tr.URL = resp.Request.RequestURI
|
||||||
|
tr.ReqHeaders = gpHTTP.HeaderToMap(resp.Request.Header)
|
||||||
|
tr.RespHeaders = gpHTTP.HeaderToMap(resp.Header)
|
||||||
|
tr.RespStatus = resp.StatusCode
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *Trace) With(what string, additional any) *Trace {
|
||||||
|
if tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.Additional == nil {
|
||||||
|
tr.Additional = map[string]any{}
|
||||||
|
}
|
||||||
|
tr.Additional[what] = additional
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *Trace) WithError(err error) *Trace {
|
||||||
|
if tr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr.Additional == nil {
|
||||||
|
tr.Additional = map[string]any{}
|
||||||
|
}
|
||||||
|
tr.Additional["error"] = err.Error()
|
||||||
|
return tr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) EnableTrace() {
|
||||||
|
m.trace = true
|
||||||
|
for _, child := range m.children {
|
||||||
|
child.parent = m
|
||||||
|
child.EnableTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) AddTracef(msg string, args ...any) *Trace {
|
||||||
|
if !m.trace {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return addTrace(&Trace{
|
||||||
|
Time: U.FormatTime(time.Now()),
|
||||||
|
Caller: m.Fullname(),
|
||||||
|
Message: fmt.Sprintf(msg, args...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) AddTraceRequest(msg string, req *Request) *Trace {
|
||||||
|
return m.AddTracef("%s", msg).WithRequest(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Middleware) AddTraceResponse(msg string, resp *Response) *Trace {
|
||||||
|
return m.AddTracef("%s", msg).WithResponse(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTrace(t *Trace) *Trace {
|
||||||
|
tracesMu.Lock()
|
||||||
|
defer tracesMu.Unlock()
|
||||||
|
if len(traces) > MaxTraceNum {
|
||||||
|
traces = traces[1:]
|
||||||
|
}
|
||||||
|
traces = append(traces, t)
|
||||||
|
return t
|
||||||
|
}
|
||||||
44
internal/net/http/middleware/x_forwarded.go
Normal file
44
internal/net/http/middleware/x_forwarded.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
xForwardedFor = "X-Forwarded-For"
|
||||||
|
xForwardedMethod = "X-Forwarded-Method"
|
||||||
|
xForwardedHost = "X-Forwarded-Host"
|
||||||
|
xForwardedProto = "X-Forwarded-Proto"
|
||||||
|
xForwardedURI = "X-Forwarded-Uri"
|
||||||
|
xForwardedPort = "X-Forwarded-Port"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SetXForwarded = &Middleware{
|
||||||
|
before: Rewrite(func(req *Request) {
|
||||||
|
req.Header.Del("Forwarded")
|
||||||
|
req.Header.Del(xForwardedFor)
|
||||||
|
req.Header.Del(xForwardedHost)
|
||||||
|
req.Header.Del(xForwardedProto)
|
||||||
|
clientIP, _, err := net.SplitHostPort(req.RemoteAddr)
|
||||||
|
if err == nil {
|
||||||
|
req.Header.Set(xForwardedFor, clientIP)
|
||||||
|
} else {
|
||||||
|
req.Header.Set(xForwardedFor, req.RemoteAddr)
|
||||||
|
}
|
||||||
|
req.Header.Set(xForwardedHost, req.Host)
|
||||||
|
if req.TLS == nil {
|
||||||
|
req.Header.Set(xForwardedProto, "http")
|
||||||
|
} else {
|
||||||
|
req.Header.Set(xForwardedProto, "https")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
var HideXForwarded = &Middleware{
|
||||||
|
before: Rewrite(func(req *Request) {
|
||||||
|
req.Header.Del("Forwarded")
|
||||||
|
req.Header.Del(xForwardedFor)
|
||||||
|
req.Header.Del(xForwardedHost)
|
||||||
|
req.Header.Del(xForwardedProto)
|
||||||
|
}),
|
||||||
|
}
|
||||||
96
internal/net/http/modify_response_writer.go
Normal file
96
internal/net/http/modify_response_writer.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// Modified from Traefik Labs's MIT-licensed code (https://github.com/traefik/traefik/blob/master/pkg/middlewares/response_modifier.go)
|
||||||
|
// Copyright (c) 2020-2024 Traefik Labs
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModifyResponseFunc func(*http.Response) error
|
||||||
|
type ModifyResponseWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
r *http.Request
|
||||||
|
|
||||||
|
headerSent bool
|
||||||
|
code int
|
||||||
|
|
||||||
|
modifier ModifyResponseFunc
|
||||||
|
modified bool
|
||||||
|
modifierErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModifyResponseWriter(w http.ResponseWriter, r *http.Request, f ModifyResponseFunc) *ModifyResponseWriter {
|
||||||
|
return &ModifyResponseWriter{
|
||||||
|
w: w,
|
||||||
|
r: r,
|
||||||
|
modifier: f,
|
||||||
|
code: http.StatusOK,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ModifyResponseWriter) WriteHeader(code int) {
|
||||||
|
if w.headerSent {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if code >= http.StatusContinue && code < http.StatusOK {
|
||||||
|
w.w.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
w.headerSent = true
|
||||||
|
w.code = code
|
||||||
|
}()
|
||||||
|
|
||||||
|
if w.modifier == nil || w.modified {
|
||||||
|
w.w.WriteHeader(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := http.Response{
|
||||||
|
Header: w.w.Header(),
|
||||||
|
Request: w.r,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.modifier(&resp); err != nil {
|
||||||
|
w.modifierErr = err
|
||||||
|
logger.Errorf("error modifying response: %s", err)
|
||||||
|
w.w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.modified = true
|
||||||
|
w.w.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ModifyResponseWriter) Header() http.Header {
|
||||||
|
return w.w.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ModifyResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
w.WriteHeader(w.code)
|
||||||
|
if w.modifierErr != nil {
|
||||||
|
return 0, w.modifierErr
|
||||||
|
}
|
||||||
|
return w.w.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hijack hijacks the connection.
|
||||||
|
func (w *ModifyResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
if h, ok := w.w.(http.Hijacker); ok {
|
||||||
|
return h.Hijack()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("not a hijacker: %T", w.w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush sends any buffered data to the client.
|
||||||
|
func (w *ModifyResponseWriter) Flush() {
|
||||||
|
if flusher, ok := w.w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
552
internal/net/http/reverse_proxy_mod.go
Normal file
552
internal/net/http/reverse_proxy_mod.go
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
// Copyright 2011 The Go Authors.
|
||||||
|
// Modified from the Go project under the a BSD-style License (https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/net/http/httputil/reverseproxy.go)
|
||||||
|
// https://cs.opensource.google/go/go/+/master:LICENSE
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
// This is a small mod on net/http/httputil/reverseproxy.go
|
||||||
|
// that boosts performance in some cases
|
||||||
|
// and compatible to other modules of this project
|
||||||
|
// Copyright (c) 2024 yusing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptrace"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/net/http/httpguts"
|
||||||
|
|
||||||
|
U "github.com/yusing/go-proxy/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A ProxyRequest contains a request to be rewritten by a [ReverseProxy].
|
||||||
|
type ProxyRequest struct {
|
||||||
|
// In is the request received by the proxy.
|
||||||
|
// The Rewrite function must not modify In.
|
||||||
|
In *http.Request
|
||||||
|
|
||||||
|
// Out is the request which will be sent by the proxy.
|
||||||
|
// The Rewrite function may modify or replace this request.
|
||||||
|
// Hop-by-hop headers are removed from this request
|
||||||
|
// before Rewrite is called.
|
||||||
|
Out *http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetXForwarded sets the X-Forwarded-For, X-Forwarded-Host, and
|
||||||
|
// X-Forwarded-Proto headers of the outbound request.
|
||||||
|
//
|
||||||
|
// - The X-Forwarded-For header is set to the client IP address.
|
||||||
|
// - The X-Forwarded-Host header is set to the host name requested
|
||||||
|
// by the client.
|
||||||
|
// - The X-Forwarded-Proto header is set to "http" or "https", depending
|
||||||
|
// on whether the inbound request was made on a TLS-enabled connection.
|
||||||
|
//
|
||||||
|
// If the outbound request contains an existing X-Forwarded-For header,
|
||||||
|
// SetXForwarded appends the client IP address to it. To append to the
|
||||||
|
// inbound request's X-Forwarded-For header (the default behavior of
|
||||||
|
// [ReverseProxy] when using a Director function), copy the header
|
||||||
|
// from the inbound request before calling SetXForwarded:
|
||||||
|
//
|
||||||
|
// rewriteFunc := func(r *httputil.ProxyRequest) {
|
||||||
|
// r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"]
|
||||||
|
// r.SetXForwarded()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ReverseProxy is an HTTP Handler that takes an incoming request and
|
||||||
|
// sends it to another server, proxying the response back to the
|
||||||
|
// client.
|
||||||
|
//
|
||||||
|
// 1xx responses are forwarded to the client if the underlying
|
||||||
|
// transport supports ClientTrace.Got1xxResponse.
|
||||||
|
type ReverseProxy struct {
|
||||||
|
// Director is a function which modifies
|
||||||
|
// the request into a new request to be sent
|
||||||
|
// using Transport. Its response is then copied
|
||||||
|
// back to the original client unmodified.
|
||||||
|
// Director must not access the provided Request
|
||||||
|
// after returning.
|
||||||
|
//
|
||||||
|
// By default, the X-Forwarded-For header is set to the
|
||||||
|
// value of the client IP address. If an X-Forwarded-For
|
||||||
|
// header already exists, the client IP is appended to the
|
||||||
|
// existing values. As a special case, if the header
|
||||||
|
// exists in the Request.Header map but has a nil value
|
||||||
|
// (such as when set by the Director func), the X-Forwarded-For
|
||||||
|
// header is not modified.
|
||||||
|
//
|
||||||
|
// To prevent IP spoofing, be sure to delete any pre-existing
|
||||||
|
// X-Forwarded-For header coming from the client or
|
||||||
|
// an untrusted proxy.
|
||||||
|
//
|
||||||
|
// Hop-by-hop headers are removed from the request after
|
||||||
|
// Director returns, which can remove headers added by
|
||||||
|
// Director. Use a Rewrite function instead to ensure
|
||||||
|
// modifications to the request are preserved.
|
||||||
|
//
|
||||||
|
// Unparsable query parameters are removed from the outbound
|
||||||
|
// request if Request.Form is set after Director returns.
|
||||||
|
//
|
||||||
|
// At most one of Rewrite or Director may be set.
|
||||||
|
Director func(*http.Request)
|
||||||
|
|
||||||
|
// The transport used to perform proxy requests.
|
||||||
|
// If nil, http.DefaultTransport is used.
|
||||||
|
Transport http.RoundTripper
|
||||||
|
|
||||||
|
// ModifyResponse is an optional function that modifies the
|
||||||
|
// Response from the backend. It is called if the backend
|
||||||
|
// returns a response at all, with any HTTP status code.
|
||||||
|
// If the backend is unreachable, the optional ErrorHandler is
|
||||||
|
// called before ModifyResponse.
|
||||||
|
//
|
||||||
|
// If ModifyResponse returns an error, ErrorHandler is called
|
||||||
|
// with its error value. If ErrorHandler is nil, its default
|
||||||
|
// implementation is used.
|
||||||
|
ModifyResponse func(*http.Response) error
|
||||||
|
|
||||||
|
ServeHTTP http.HandlerFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func singleJoiningSlash(a, b string) string {
|
||||||
|
aslash := strings.HasSuffix(a, "/")
|
||||||
|
bslash := strings.HasPrefix(b, "/")
|
||||||
|
switch {
|
||||||
|
case aslash && bslash:
|
||||||
|
return a + b[1:]
|
||||||
|
case !aslash && !bslash:
|
||||||
|
return a + "/" + b
|
||||||
|
}
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinURLPath(a, b *url.URL) (path, rawpath string) {
|
||||||
|
if a.RawPath == "" && b.RawPath == "" {
|
||||||
|
return singleJoiningSlash(a.Path, b.Path), ""
|
||||||
|
}
|
||||||
|
// Same as singleJoiningSlash, but uses EscapedPath to determine
|
||||||
|
// whether a slash should be added
|
||||||
|
apath := a.EscapedPath()
|
||||||
|
bpath := b.EscapedPath()
|
||||||
|
|
||||||
|
aslash := strings.HasSuffix(apath, "/")
|
||||||
|
bslash := strings.HasPrefix(bpath, "/")
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case aslash && bslash:
|
||||||
|
return a.Path + b.Path[1:], apath + bpath[1:]
|
||||||
|
case !aslash && !bslash:
|
||||||
|
return a.Path + "/" + b.Path, apath + "/" + bpath
|
||||||
|
}
|
||||||
|
return a.Path + b.Path, apath + bpath
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReverseProxy returns a new [ReverseProxy] that routes
|
||||||
|
// URLs to the scheme, host, and base path provided in target. If the
|
||||||
|
// target's path is "/base" and the incoming request was for "/dir",
|
||||||
|
// the target request will be for /base/dir.
|
||||||
|
//
|
||||||
|
// NewReverseProxy does not rewrite the Host header.
|
||||||
|
//
|
||||||
|
// To customize the ReverseProxy behavior beyond what
|
||||||
|
// NewReverseProxy provides, use ReverseProxy directly
|
||||||
|
// with a Rewrite function. The ProxyRequest SetURL method
|
||||||
|
// may be used to route the outbound request. (Note that SetURL,
|
||||||
|
// unlike NewReverseProxy, rewrites the Host header
|
||||||
|
// of the outbound request by default.)
|
||||||
|
//
|
||||||
|
// proxy := &ReverseProxy{
|
||||||
|
// Rewrite: func(r *ProxyRequest) {
|
||||||
|
// r.SetURL(target)
|
||||||
|
// r.Out.Host = r.In.Host // if desired
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
|
func NewReverseProxy(target *url.URL, transport http.RoundTripper) *ReverseProxy {
|
||||||
|
if transport == nil {
|
||||||
|
panic("nil transport")
|
||||||
|
}
|
||||||
|
rp := &ReverseProxy{
|
||||||
|
Director: func(req *http.Request) {
|
||||||
|
rewriteRequestURL(req, target)
|
||||||
|
},
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
rp.ServeHTTP = rp.serveHTTP
|
||||||
|
return rp
|
||||||
|
}
|
||||||
|
|
||||||
|
func rewriteRequestURL(req *http.Request, target *url.URL) {
|
||||||
|
targetQuery := target.RawQuery
|
||||||
|
req.URL.Scheme = target.Scheme
|
||||||
|
req.URL.Host = target.Host
|
||||||
|
req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL)
|
||||||
|
if targetQuery == "" || req.URL.RawQuery == "" {
|
||||||
|
req.URL.RawQuery = targetQuery + req.URL.RawQuery
|
||||||
|
} else {
|
||||||
|
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyHeader(dst, src http.Header) {
|
||||||
|
for k, vv := range src {
|
||||||
|
for _, v := range vv {
|
||||||
|
dst.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hop-by-hop headers. These are removed when sent to the backend.
|
||||||
|
// As of RFC 7230, hop-by-hop headers are required to appear in the
|
||||||
|
// Connection header field. These are the headers defined by the
|
||||||
|
// obsoleted RFC 2616 (section 13.5.1) and are used for backward
|
||||||
|
// compatibility.
|
||||||
|
var hopHeaders = []string{
|
||||||
|
"Connection",
|
||||||
|
"Proxy-Connection", // non-standard but still sent by libcurl and rejected by e.g. google
|
||||||
|
"Keep-Alive",
|
||||||
|
"Proxy-Authenticate",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"Te", // canonicalized version of "TE"
|
||||||
|
"Trailer", // not Trailers per URL above; https://www.rfc-editor.org/errata_search.php?eid=4522
|
||||||
|
"Transfer-Encoding",
|
||||||
|
"Upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReverseProxy) errorHandler(rw http.ResponseWriter, r *http.Request, err error, writeHeader bool) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, context.Canceled),
|
||||||
|
errors.Is(err, io.EOF):
|
||||||
|
logger.Debugf("http proxy to %s error: %s", r.URL.String(), err)
|
||||||
|
default:
|
||||||
|
logger.Errorf("http proxy to %s error: %s", r.URL.String(), err)
|
||||||
|
}
|
||||||
|
if writeHeader {
|
||||||
|
rw.WriteHeader(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyResponse conditionally runs the optional ModifyResponse hook
|
||||||
|
// and reports whether the request should proceed.
|
||||||
|
func (p *ReverseProxy) modifyResponse(rw http.ResponseWriter, res *http.Response, req *http.Request) bool {
|
||||||
|
if p.ModifyResponse == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if err := p.ModifyResponse(res); err != nil {
|
||||||
|
res.Body.Close()
|
||||||
|
p.errorHandler(rw, req, err, true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReverseProxy) serveHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
transport := p.Transport
|
||||||
|
|
||||||
|
ctx := req.Context()
|
||||||
|
if ctx.Done() != nil {
|
||||||
|
// CloseNotifier predates context.Context, and has been
|
||||||
|
// entirely superseded by it. If the request contains
|
||||||
|
// a Context that carries a cancellation signal, don't
|
||||||
|
// bother spinning up a goroutine to watch the CloseNotify
|
||||||
|
// channel (if any).
|
||||||
|
//
|
||||||
|
// If the request Context has a nil Done channel (which
|
||||||
|
// means it is either context.Background, or a custom
|
||||||
|
// Context implementation with no cancellation signal),
|
||||||
|
// then consult the CloseNotifier if available.
|
||||||
|
} else if cn, ok := rw.(http.CloseNotifier); ok {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
notifyChan := cn.CloseNotify()
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-notifyChan:
|
||||||
|
cancel()
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
outreq := req.Clone(ctx)
|
||||||
|
if req.ContentLength == 0 {
|
||||||
|
outreq.Body = nil // Issue 16036: nil Body for http.Transport retries
|
||||||
|
}
|
||||||
|
if outreq.Body != nil {
|
||||||
|
// Reading from the request body after returning from a handler is not
|
||||||
|
// allowed, and the RoundTrip goroutine that reads the Body can outlive
|
||||||
|
// this handler. This can lead to a crash if the handler panics (see
|
||||||
|
// Issue 46866). Although calling Close doesn't guarantee there isn't
|
||||||
|
// any Read in flight after the handle returns, in practice it's safe to
|
||||||
|
// read after closing it.
|
||||||
|
defer outreq.Body.Close()
|
||||||
|
}
|
||||||
|
if outreq.Header == nil {
|
||||||
|
outreq.Header = make(http.Header) // Issue 33142: historical behavior was to always allocate
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Director(outreq)
|
||||||
|
outreq.Close = false
|
||||||
|
|
||||||
|
reqUpType := UpgradeType(outreq.Header)
|
||||||
|
if !IsPrint(reqUpType) {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("client tried to switch to invalid protocol %q", reqUpType), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveHopByHopHeaders(outreq.Header)
|
||||||
|
|
||||||
|
// Issue 21096: tell backend applications that care about trailer support
|
||||||
|
// that we support trailers. (We do, but we don't go out of our way to
|
||||||
|
// advertise that unless the incoming client request thought it was worth
|
||||||
|
// mentioning.) Note that we look at req.Header, not outreq.Header, since
|
||||||
|
// the latter has passed through removeHopByHopHeaders.
|
||||||
|
if httpguts.HeaderValuesContainsToken(req.Header["Te"], "trailers") {
|
||||||
|
outreq.Header.Set("Te", "trailers")
|
||||||
|
}
|
||||||
|
|
||||||
|
// After stripping all the hop-by-hop connection headers above, add back any
|
||||||
|
// necessary for protocol upgrades, such as for websockets.
|
||||||
|
if reqUpType != "" {
|
||||||
|
outreq.Header.Set("Connection", "Upgrade")
|
||||||
|
outreq.Header.Set("Upgrade", reqUpType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
|
||||||
|
// If we aren't the first proxy retain prior
|
||||||
|
// X-Forwarded-For information as a comma+space
|
||||||
|
// separated list and fold multiple headers into one.
|
||||||
|
prior, ok := outreq.Header["X-Forwarded-For"]
|
||||||
|
omit := ok && prior == nil // Issue 38079: nil now means don't populate the header
|
||||||
|
if len(prior) > 0 {
|
||||||
|
clientIP = strings.Join(prior, ", ") + ", " + clientIP
|
||||||
|
}
|
||||||
|
if !omit {
|
||||||
|
outreq.Header.Set("X-Forwarded-For", clientIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.TLS == nil {
|
||||||
|
outreq.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
outreq.Header.Set("X-Forwarded-Scheme", "http")
|
||||||
|
} else {
|
||||||
|
outreq.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
outreq.Header.Set("X-Forwarded-Scheme", "https")
|
||||||
|
}
|
||||||
|
outreq.Header.Set("X-Forwarded-Host", req.Host)
|
||||||
|
|
||||||
|
if _, ok := outreq.Header["User-Agent"]; !ok {
|
||||||
|
// If the outbound request doesn't have a User-Agent header set,
|
||||||
|
// don't send the default Go HTTP client User-Agent.
|
||||||
|
outreq.Header.Set("User-Agent", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
roundTripMutex sync.Mutex
|
||||||
|
roundTripDone bool
|
||||||
|
)
|
||||||
|
trace := &httptrace.ClientTrace{
|
||||||
|
Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
|
||||||
|
roundTripMutex.Lock()
|
||||||
|
defer roundTripMutex.Unlock()
|
||||||
|
if roundTripDone {
|
||||||
|
// If RoundTrip has returned, don't try to further modify
|
||||||
|
// the ResponseWriter's header map.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
h := rw.Header()
|
||||||
|
copyHeader(h, http.Header(header))
|
||||||
|
rw.WriteHeader(code)
|
||||||
|
|
||||||
|
// Clear headers, it's not automatically done by ResponseWriter.WriteHeader() for 1xx responses
|
||||||
|
clear(h)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
outreq = outreq.WithContext(httptrace.WithClientTrace(outreq.Context(), trace))
|
||||||
|
|
||||||
|
res, err := transport.RoundTrip(outreq)
|
||||||
|
roundTripMutex.Lock()
|
||||||
|
roundTripDone = true
|
||||||
|
roundTripMutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
p.errorHandler(rw, outreq, err, false)
|
||||||
|
errMsg := err.Error()
|
||||||
|
res = &http.Response{
|
||||||
|
Status: http.StatusText(http.StatusBadGateway),
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Proto: outreq.Proto,
|
||||||
|
ProtoMajor: outreq.ProtoMajor,
|
||||||
|
ProtoMinor: outreq.ProtoMinor,
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte("Origin server is not reachable."))),
|
||||||
|
Request: outreq,
|
||||||
|
ContentLength: int64(len(errMsg)),
|
||||||
|
TLS: outreq.TLS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
|
||||||
|
if res.StatusCode == http.StatusSwitchingProtocols {
|
||||||
|
if !p.modifyResponse(rw, res, outreq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.handleUpgradeResponse(rw, outreq, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoveHopByHopHeaders(res.Header)
|
||||||
|
|
||||||
|
if !p.modifyResponse(rw, res, outreq) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
copyHeader(rw.Header(), res.Header)
|
||||||
|
|
||||||
|
// The "Trailer" header isn't included in the Transport's response,
|
||||||
|
// at least for *http.Transport. Build it up from Trailer.
|
||||||
|
announcedTrailers := len(res.Trailer)
|
||||||
|
if announcedTrailers > 0 {
|
||||||
|
trailerKeys := make([]string, 0, len(res.Trailer))
|
||||||
|
for k := range res.Trailer {
|
||||||
|
trailerKeys = append(trailerKeys, k)
|
||||||
|
}
|
||||||
|
rw.Header().Add("Trailer", strings.Join(trailerKeys, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.WriteHeader(res.StatusCode)
|
||||||
|
|
||||||
|
err = U.Copy2(req.Context(), rw, res.Body)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
p.errorHandler(rw, req, err, true)
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.Body.Close() // close now, instead of defer, to populate res.Trailer
|
||||||
|
|
||||||
|
if len(res.Trailer) > 0 {
|
||||||
|
// Force chunking if we saw a response trailer.
|
||||||
|
// This prevents net/http from calculating the length for short
|
||||||
|
// bodies and adding a Content-Length.
|
||||||
|
http.NewResponseController(rw).Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res.Trailer) == announcedTrailers {
|
||||||
|
copyHeader(rw.Header(), res.Trailer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, vv := range res.Trailer {
|
||||||
|
k = http.TrailerPrefix + k
|
||||||
|
for _, v := range vv {
|
||||||
|
rw.Header().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpgradeType(h http.Header) string {
|
||||||
|
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.Get("Upgrade")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveHopByHopHeaders removes hop-by-hop headers.
|
||||||
|
func RemoveHopByHopHeaders(h http.Header) {
|
||||||
|
// RFC 7230, section 6.1: Remove headers listed in the "Connection" header.
|
||||||
|
for _, f := range h["Connection"] {
|
||||||
|
for _, sf := range strings.Split(f, ",") {
|
||||||
|
if sf = textproto.TrimString(sf); sf != "" {
|
||||||
|
h.Del(sf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RFC 2616, section 13.5.1: Remove a set of known hop-by-hop headers.
|
||||||
|
// This behavior is superseded by the RFC 7230 Connection header, but
|
||||||
|
// preserve it for backwards compatibility.
|
||||||
|
for _, f := range hopHeaders {
|
||||||
|
h.Del(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) {
|
||||||
|
reqUpType := UpgradeType(req.Header)
|
||||||
|
resUpType := UpgradeType(res.Header)
|
||||||
|
if !IsPrint(resUpType) { // We know reqUpType is ASCII, it's checked by the caller.
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch to invalid protocol %q", resUpType), true)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(reqUpType, resUpType) {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("backend tried to switch protocol %q when %q was requested", resUpType, reqUpType), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backConn, ok := res.Body.(io.ReadWriteCloser)
|
||||||
|
if !ok {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("internal error: 101 switching protocols response with non-writable body"), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := http.NewResponseController(rw)
|
||||||
|
conn, brw, hijackErr := rc.Hijack()
|
||||||
|
if errors.Is(hijackErr, http.ErrNotSupported) {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("can't switch protocols using non-Hijacker ResponseWriter type %T", rw), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backConnCloseCh := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
// Ensure that the cancellation of a request closes the backend.
|
||||||
|
// See issue https://golang.org/issue/35559.
|
||||||
|
select {
|
||||||
|
case <-req.Context().Done():
|
||||||
|
case <-backConnCloseCh:
|
||||||
|
}
|
||||||
|
backConn.Close()
|
||||||
|
}()
|
||||||
|
defer close(backConnCloseCh)
|
||||||
|
|
||||||
|
if hijackErr != nil {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("hijack failed on protocol switch: %w", hijackErr), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
copyHeader(rw.Header(), res.Header)
|
||||||
|
|
||||||
|
res.Header = rw.Header()
|
||||||
|
res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above
|
||||||
|
if err := res.Write(brw); err != nil {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("response write: %s", err), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := brw.Flush(); err != nil {
|
||||||
|
p.errorHandler(rw, req, fmt.Errorf("response flush: %s", err), true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bdp := U.NewBidirectionalPipe(req.Context(), conn, backConn)
|
||||||
|
bdp.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsPrint(s string) bool {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < ' ' || s[i] > '~' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger = logrus.WithField("module", "http")
|
||||||
7
internal/net/http/status_code.go
Normal file
7
internal/net/http/status_code.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func IsSuccess(status int) bool {
|
||||||
|
return status >= http.StatusOK && status < http.StatusMultipleChoices
|
||||||
|
}
|
||||||
146
internal/proxy/entry.go
Normal file
146
internal/proxy/entry.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
D "github.com/yusing/go-proxy/internal/docker"
|
||||||
|
E "github.com/yusing/go-proxy/internal/error"
|
||||||
|
T "github.com/yusing/go-proxy/internal/proxy/fields"
|
||||||
|
"github.com/yusing/go-proxy/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
ReverseProxyEntry struct { // real model after validation
|
||||||
|
Alias T.Alias
|
||||||
|
Scheme T.Scheme
|
||||||
|
URL *url.URL
|
||||||
|
NoTLSVerify bool
|
||||||
|
PathPatterns T.PathPatterns
|
||||||
|
Middlewares D.NestedLabelMap
|
||||||
|
|
||||||
|
/* Docker only */
|
||||||
|
IdleTimeout time.Duration
|
||||||
|
WakeTimeout time.Duration
|
||||||
|
StopMethod T.StopMethod
|
||||||
|
StopTimeout int
|
||||||
|
StopSignal T.Signal
|
||||||
|
DockerHost string
|
||||||
|
ContainerName string
|
||||||
|
ContainerID string
|
||||||
|
ContainerRunning bool
|
||||||
|
}
|
||||||
|
StreamEntry struct {
|
||||||
|
Alias T.Alias `json:"alias"`
|
||||||
|
Scheme T.StreamScheme `json:"scheme"`
|
||||||
|
Host T.Host `json:"host"`
|
||||||
|
Port T.StreamPort `json:"port"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (rp *ReverseProxyEntry) UseIdleWatcher() bool {
|
||||||
|
return rp.IdleTimeout > 0 && rp.DockerHost != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rp *ReverseProxyEntry) IsDocker() bool {
|
||||||
|
return rp.DockerHost != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateEntry(m *types.RawEntry) (any, E.NestedError) {
|
||||||
|
m.FillMissingFields()
|
||||||
|
|
||||||
|
scheme, err := T.NewScheme(m.Scheme)
|
||||||
|
if err.HasError() {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry any
|
||||||
|
e := E.NewBuilder("error validating entry")
|
||||||
|
if scheme.IsStream() {
|
||||||
|
entry = validateStreamEntry(m, e)
|
||||||
|
} else {
|
||||||
|
entry = validateRPEntry(m, scheme, e)
|
||||||
|
}
|
||||||
|
if err := e.Build(); err.HasError() {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRPEntry(m *types.RawEntry, s T.Scheme, b E.Builder) *ReverseProxyEntry {
|
||||||
|
var stopTimeOut time.Duration
|
||||||
|
|
||||||
|
host, err := T.ValidateHost(m.Host)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
port, err := T.ValidatePort(m.Port)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
pathPatterns, err := T.ValidatePathPatterns(m.PathPatterns)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
url, err := E.Check(url.Parse(fmt.Sprintf("%s://%s:%d", s, host, port)))
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
idleTimeout, err := T.ValidateDurationPostitive(m.IdleTimeout)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
wakeTimeout, err := T.ValidateDurationPostitive(m.WakeTimeout)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
stopMethod, err := T.ValidateStopMethod(m.StopMethod)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
if stopMethod == T.StopMethodStop {
|
||||||
|
stopTimeOut, err = T.ValidateDurationPostitive(m.StopTimeout)
|
||||||
|
b.Add(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stopSignal, err := T.ValidateSignal(m.StopSignal)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
if err.HasError() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ReverseProxyEntry{
|
||||||
|
Alias: T.NewAlias(m.Alias),
|
||||||
|
Scheme: s,
|
||||||
|
URL: url,
|
||||||
|
NoTLSVerify: m.NoTLSVerify,
|
||||||
|
PathPatterns: pathPatterns,
|
||||||
|
Middlewares: m.Middlewares,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
WakeTimeout: wakeTimeout,
|
||||||
|
StopMethod: stopMethod,
|
||||||
|
StopTimeout: int(stopTimeOut.Seconds()), // docker api takes integer seconds for timeout argument
|
||||||
|
StopSignal: stopSignal,
|
||||||
|
DockerHost: m.DockerHost,
|
||||||
|
ContainerName: m.ContainerName,
|
||||||
|
ContainerID: m.ContainerID,
|
||||||
|
ContainerRunning: m.Running,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStreamEntry(m *types.RawEntry, b E.Builder) *StreamEntry {
|
||||||
|
host, err := T.ValidateHost(m.Host)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
port, err := T.ValidateStreamPort(m.Port)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
scheme, err := T.ValidateStreamScheme(m.Scheme)
|
||||||
|
b.Add(err)
|
||||||
|
|
||||||
|
if b.HasError() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StreamEntry{
|
||||||
|
Alias: T.NewAlias(m.Alias),
|
||||||
|
Scheme: *scheme,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
}
|
||||||
|
}
|
||||||
6
internal/proxy/fields/alias.go
Normal file
6
internal/proxy/fields/alias.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package fields
|
||||||
|
|
||||||
|
type (
|
||||||
|
Alias string
|
||||||
|
NewAlias = Alias
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user